diff --git a/README.md b/README.md index e4de615..65988fc 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Install or disable them dynamically with the `/plugin` command — enabling you - [reddit-community-builder](./plugins/reddit-community-builder) - [tiktok-strategist](./plugins/tiktok-strategist) - [twitter-engager](./plugins/twitter-engager) +- [unslop](./plugins/unslop) ### Project & Product Management - [discuss](./plugins/discuss) diff --git a/plugins/unslop/.claude-plugin/plugin.json b/plugins/unslop/.claude-plugin/plugin.json new file mode 100644 index 0000000..911f75e --- /dev/null +++ b/plugins/unslop/.claude-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "unslop", + "description": "Strip AI writing patterns from Claude Code output before publishing. Removes sycophancy, stock vocabulary, hedging stacks, and em-dash pileups. Engineers sentence burstiness. Code and URLs pass through unchanged.", + "version": "0.4.5", + "author": { + "name": "Mohamed Abdallah", + "url": "https://github.com/MohamedAbdallah-14" + }, + "homepage": "https://github.com/MohamedAbdallah-14/unslop", + "keywords": ["writing", "ai-writing", "content-quality", "text-processing", "publishing"], + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/unslop-activate.js\"", + "timeout": 5, + "statusMessage": "Loading unslop mode..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/unslop-mode-tracker.js\"", + "timeout": 5, + "statusMessage": "Tracking unslop mode..." + } + ] + } + ] + } +} diff --git a/plugins/unslop/hooks/unslop-activate.js b/plugins/unslop/hooks/unslop-activate.js new file mode 100644 index 0000000..9d235c6 --- /dev/null +++ b/plugins/unslop/hooks/unslop-activate.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// unslop — Claude Code SessionStart activation hook +// +// Runs on every session start: +// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.unslop-active (statusline reads this) +// 2. Emits unslop ruleset as hidden SessionStart context +// 3. Detects missing statusline config and emits setup nudge + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { + getDefaultMode, safeWriteFlag, getFlagPath, + getTurnCounterPath, resetTurnCount, +} = require('./unslop-config'); + +const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +const flagPath = getFlagPath(); +const counterPath = getTurnCounterPath(); +const settingsPath = path.join(claudeDir, 'settings.json'); + +const mode = getDefaultMode(); + +// Persona-drift reset: a new session always starts at turn 0. RMTBench / +// HorizonBench report that long contexts accumulate drift; the counter is +// only meaningful within a single session, so we zero it here. +resetTurnCount(counterPath); + +if (mode === 'off') { + try { fs.unlinkSync(flagPath); } catch (e) {} + process.stdout.write('OK'); + process.exit(0); +} + +safeWriteFlag(flagPath, mode); + +// Independent modes have their own skill files — don't emit the full ruleset. +const INDEPENDENT_MODES = new Set(['commit', 'review']); + +if (INDEPENDENT_MODES.has(mode)) { + process.stdout.write('UNSLOP MODE ACTIVE — level: ' + mode + '. Behavior defined by /unslop-' + mode + ' skill.'); + process.exit(0); +} + +// Read SKILL.md — the single source of truth for unslop behavior. +// Plugin installs: __dirname = /hooks/, SKILL.md at /skills/unslop/SKILL.md +// Standalone installs: __dirname = $CLAUDE_CONFIG_DIR/hooks/, SKILL.md won't exist — falls back to activation rule then hardcoded rules. +let skillContent = ''; +try { + skillContent = fs.readFileSync( + path.join(__dirname, '..', 'skills', 'unslop', 'SKILL.md'), 'utf8' + ); +} catch (e) { /* try activation rule next */ } + +// Fallback: try the activation rule file (lighter weight than full SKILL.md) +let activationRule = ''; +if (!skillContent) { + try { + activationRule = fs.readFileSync( + path.join(__dirname, '..', 'rules', 'unslop-activate.md'), 'utf8' + ).trim(); + } catch (e) { /* will use hardcoded fallback */ } +} + +let output; + +if (skillContent) { + const body = skillContent.replace(/^---[\s\S]*?---\s*/, ''); + + // Filter intensity table and examples to the active level + const filtered = body.split('\n').reduce((acc, line) => { + const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/); + if (tableRowMatch) { + if (tableRowMatch[1] === mode) { + acc.push(line); + } + return acc; + } + + const exampleMatch = line.match(/^- (\S+?):\s/); + if (exampleMatch) { + if (exampleMatch[1] === mode) { + acc.push(line); + } + return acc; + } + + acc.push(line); + return acc; + }, []); + + output = 'UNSLOP MODE ACTIVE — level: ' + mode + '\n\n' + filtered.join('\n'); +} else if (activationRule) { + output = 'UNSLOP MODE ACTIVE — level: ' + mode + '\n\n' + activationRule; +} else { + output = + 'UNSLOP MODE ACTIVE — level: ' + mode + '\n\n' + + 'Write like a careful human. All technical substance stays exact. Only AI-slop dies.\n\n' + + '## Persistence\n\n' + + 'ACTIVE EVERY RESPONSE. No revert after many turns. No drift back into AI-template English.\n' + + 'Off only: "stop unslop" / "normal mode".\n\n' + + 'Current level: **' + mode + '**. Switch: `/unslop subtle|balanced|full|voice-match|anti-detector`.\n\n' + + '## Rules\n\n' + + 'Drop: sycophancy ("great question", "I\'d be happy to"), stock vocab (delve/tapestry/testament/seamless/holistic/leverage-as-filler), ' + + 'hedging stacks ("it\'s important to note that"), tricolon padding, em-dash pileups, performative balance, tidy five-paragraph shapes.\n\n' + + 'Keep: technical terms exact, code unchanged, real uncertainty when honest.\n' + + 'Engineer burstiness: mix short and long sentences deliberately.\n\n' + + 'Pattern: [concrete observation]. [why]. [what to do next].\n\n' + + '## Auto-Clarity\n\n' + + 'Drop unslop style for: security warnings, irreversible actions, legal/medical/financial precision, user confused. Resume after.\n\n' + + '## Boundaries\n\n' + + 'Code/commits/PRs: write normal. "stop unslop" or "normal mode": revert. Level persists until changed or session ends.'; +} + +// Detect missing statusline config — nudge Claude to help set it up +try { + let hasStatusline = false; + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (settings.statusLine) { + hasStatusline = true; + } + } + + if (!hasStatusline) { + const isWindows = process.platform === 'win32'; + const scriptName = isWindows ? 'unslop-statusline.ps1' : 'unslop-statusline.sh'; + const scriptPath = path.join(__dirname, scriptName); + const command = isWindows + ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` + : `bash "${scriptPath}"`; + const statusLineSnippet = + '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The unslop plugin includes a statusline badge showing active mode " + + "(e.g. [unslop], [unslop:full]). It is not configured yet. " + + "To enable, add this to " + path.join(claudeDir, 'settings.json') + ": " + + statusLineSnippet + " " + + "Proactively offer to set this up for the user on first interaction."; + } +} catch (e) { + // Silent fail — don't block session start over statusline detection +} + +process.stdout.write(output); diff --git a/plugins/unslop/hooks/unslop-config.js b/plugins/unslop/hooks/unslop-config.js new file mode 100644 index 0000000..4ed7516 --- /dev/null +++ b/plugins/unslop/hooks/unslop-config.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// unslop — shared configuration resolver +// +// Resolution order for default mode: +// 1. UNSLOP_DEFAULT_MODE environment variable +// 2. Config file defaultMode field: +// - $XDG_CONFIG_HOME/unslop/config.json (any platform, if set) +// - ~/.config/unslop/config.json (macOS / Linux fallback) +// - %APPDATA%\unslop\config.json (Windows fallback) +// 3. 'balanced' + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const VALID_MODES = [ + 'off', 'subtle', 'balanced', 'full', + 'voice-match', 'anti-detector', + 'commit', 'review' +]; + +function getConfigDir() { + if (process.env.XDG_CONFIG_HOME) { + return path.join(process.env.XDG_CONFIG_HOME, 'unslop'); + } + if (process.platform === 'win32') { + return path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + 'unslop' + ); + } + return path.join(os.homedir(), '.config', 'unslop'); +} + +function getConfigPath() { + return path.join(getConfigDir(), 'config.json'); +} + +function getDefaultMode() { + const envMode = process.env.UNSLOP_DEFAULT_MODE; + if (envMode && VALID_MODES.includes(envMode.toLowerCase())) { + return envMode.toLowerCase(); + } + + try { + const configPath = getConfigPath(); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) { + return config.defaultMode.toLowerCase(); + } + } catch (e) { + // Config file doesn't exist or is invalid + } + + return 'balanced'; +} + +// Symlink-safe flag file write. +// Refuses symlinks at the target file and at the immediate parent directory, +// uses O_NOFOLLOW where available, writes atomically via temp + rename with +// 0600 permissions. Protects against local attackers replacing the predictable +// flag path with a symlink to clobber other files. +function safeWriteFlag(flagPath, content) { + try { + const flagDir = path.dirname(flagPath); + fs.mkdirSync(flagDir, { recursive: true }); + + try { + if (fs.lstatSync(flagDir).isSymbolicLink()) return; + } catch (e) { + return; + } + + try { + if (fs.lstatSync(flagPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + + const tempPath = path.join(flagDir, `.unslop-active.${process.pid}.${Date.now()}`); + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(tempPath, flags, 0o600); + fs.writeSync(fd, String(content)); + try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + fs.renameSync(tempPath, flagPath); + } catch (e) { + // Silent fail — flag is best-effort + } +} + +// Symlink-safe, size-capped, whitelist-validated flag file read. +// Returns null on any anomaly — never inject untrusted bytes into model context. +const MAX_FLAG_BYTES = 64; + +function readFlag(flagPath) { + try { + let st; + try { + st = fs.lstatSync(flagPath); + } catch (e) { + return null; + } + if (st.isSymbolicLink() || !st.isFile()) return null; + if (st.size > MAX_FLAG_BYTES) return null; + + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd; + let out; + try { + fd = fs.openSync(flagPath, flags); + const buf = Buffer.alloc(MAX_FLAG_BYTES); + const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); + out = buf.slice(0, n).toString('utf8'); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + + const raw = out.trim().toLowerCase(); + if (!VALID_MODES.includes(raw)) return null; + return raw; + } catch (e) { + return null; + } +} + +function getFlagPath() { + const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + return path.join(claudeDir, '.unslop-active'); +} + +// Persona-drift reinforcement counter. Tracks how many user turns have +// passed in this session while unslop has been active. RMTBench measures +// >30% persona degradation after 8–12 turns; HorizonBench (arXiv +// 2604.17283, Apr 2026) benchmarks preference evolution over time. We use +// the counter to re-emit a shorter reinforcement banner at predetermined +// drift-risk checkpoints rather than every turn (which would get tuned out). +function getTurnCounterPath() { + const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + return path.join(claudeDir, '.unslop-turn-count'); +} + +// Read the counter. Same symlink-safe / size-capped discipline as readFlag. +function readTurnCount(counterPath) { + try { + let st; + try { + st = fs.lstatSync(counterPath); + } catch (e) { + return 0; + } + if (st.isSymbolicLink() || !st.isFile()) return 0; + if (st.size > 32) return 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd, raw; + try { + fd = fs.openSync(counterPath, flags); + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + raw = buf.slice(0, n).toString('utf8').trim(); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + const n = parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 1_000_000) return 0; + return n; + } catch (e) { + return 0; + } +} + +// Symlink-safe atomic-rename write of the counter. Uses the same pattern as +// safeWriteFlag to resist local-attacker symlink games. +function writeTurnCount(counterPath, n) { + try { + const dir = path.dirname(counterPath); + fs.mkdirSync(dir, { recursive: true }); + try { + if (fs.lstatSync(dir).isSymbolicLink()) return; + } catch (e) { return; } + try { + if (fs.lstatSync(counterPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + const tempPath = path.join(dir, `.unslop-turn-count.${process.pid}.${Date.now()}`); + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(tempPath, flags, 0o600); + fs.writeSync(fd, String(n)); + try { fs.fchmodSync(fd, 0o600); } catch (e) {} + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + fs.renameSync(tempPath, counterPath); + } catch (e) { + // Silent fail — drift counter is best-effort + } +} + +// Reset the counter (on session start / mode change). Safe no-op if missing. +function resetTurnCount(counterPath) { + try { fs.unlinkSync(counterPath); } catch (e) { /* noop */ } +} + +module.exports = { + getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, + safeWriteFlag, readFlag, getFlagPath, + getTurnCounterPath, readTurnCount, writeTurnCount, resetTurnCount +}; diff --git a/plugins/unslop/hooks/unslop-mode-tracker.js b/plugins/unslop/hooks/unslop-mode-tracker.js new file mode 100644 index 0000000..99a9d30 --- /dev/null +++ b/plugins/unslop/hooks/unslop-mode-tracker.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// unslop — UserPromptSubmit hook to track which unslop mode is active +// Inspects user input for /unslop commands and natural language activation, +// writes mode to flag file, and emits per-turn style reinforcement. + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { + getDefaultMode, safeWriteFlag, readFlag, getFlagPath, + getTurnCounterPath, readTurnCount, writeTurnCount, resetTurnCount, +} = require('./unslop-config'); + +const flagPath = getFlagPath(); +const counterPath = getTurnCounterPath(); + +// Persona-drift reinforcement checkpoints. RMTBench / HorizonBench (Apr +// 2026) measure persona degradation beginning around turn 8 and becoming +// severe by turn 12–16. We re-emit at these points rather than every turn +// so the reinforcement stays salient. After turn 32 we fall back to every +// 16 turns to avoid spam in marathon sessions. +const DRIFT_CHECKPOINTS = new Set([8, 16, 24, 32]); +function isDriftCheckpoint(turn) { + if (DRIFT_CHECKPOINTS.has(turn)) return true; + if (turn > 32 && turn % 16 === 0) return true; + return false; +} + +let input = ''; +process.stdin.on('data', chunk => { input += chunk; }); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + const prompt = (data.prompt || '').trim(); + const promptLower = prompt.toLowerCase(); + + // Natural language activation (e.g. "activate unslop", "turn on unslop mode", + // "make this sound human", "humanize this"). + if (/\b(activate|enable|turn on|start)\b.*\bunslop\b/i.test(prompt) || + /\bunslop\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) || + /\b(humanize|de-?slop|make.*sound human|less robotic)\b/i.test(prompt)) { + if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) { + const mode = getDefaultMode(); + if (mode !== 'off') { + safeWriteFlag(flagPath, mode); + } + } + } + + // Match /unslop slash commands + if (promptLower.startsWith('/unslop')) { + const parts = promptLower.split(/\s+/); + const cmd = parts[0]; + const arg = parts[1] || ''; + + let mode = null; + + if (cmd === '/unslop-commit') { + mode = 'commit'; + } else if (cmd === '/unslop-review') { + mode = 'review'; + } else if (cmd === '/unslop' || cmd === '/unslop:unslop') { + if (arg === 'subtle') mode = 'subtle'; + else if (arg === 'balanced') mode = 'balanced'; + else if (arg === 'full') mode = 'full'; + else if (arg === 'voice-match') mode = 'voice-match'; + else if (arg === 'anti-detector') mode = 'anti-detector'; + else mode = getDefaultMode(); + } + + if (mode && mode !== 'off') { + safeWriteFlag(flagPath, mode); + } else if (mode === 'off') { + try { fs.unlinkSync(flagPath); } catch (e) {} + } + } + + // Also match /unslop-file (the file-rewriter command) — set mode to current default + if (promptLower.startsWith('/humanize') && !promptLower.startsWith('/unslop')) { + const mode = getDefaultMode(); + if (mode !== 'off') { + safeWriteFlag(flagPath, mode); + } + } + + // Detect deactivation — natural language and explicit stop phrases + if (/\b(stop|disable|deactivate|turn off)\b.*\bunslop\b/i.test(prompt) || + /\bunslop\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || + /\bnormal mode\b/i.test(prompt) || + /\brobotic mode\b/i.test(prompt)) { + try { fs.unlinkSync(flagPath); } catch (e) {} + resetTurnCount(counterPath); + } + + // Per-turn reinforcement: emit a structured reminder when unslop is active. + // The SessionStart hook injects the full ruleset once, but models lose it + // when other plugins inject competing style instructions every turn. + // Skip independent modes (commit, review) — they have their own skill behavior. + const INDEPENDENT_MODES = new Set(['commit', 'review']); + const activeMode = readFlag(flagPath); + if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { + // Advance the persona-drift counter and decide whether this turn + // warrants an expanded reinforcement. Best-effort: counter failures + // degrade to the standard per-turn banner. + const turn = readTurnCount(counterPath) + 1; + writeTurnCount(counterPath, turn); + + let additional = "UNSLOP MODE ACTIVE (" + activeMode + "). " + + "Drop sycophancy/stock-vocab/hedging-stacks/tricolons/em-dash-pileups. " + + "Engineer burstiness. Code/commits/security: write normal."; + + if (isDriftCheckpoint(turn)) { + // RMTBench / HorizonBench: at these turn counts models silently + // drift back to template English. Re-state the ruleset header + // explicitly so the model has fresh context to anchor against. + additional += + " [drift-check turn " + turn + "] Persona drift risk is elevated after " + + "long contexts (RMTBench / HorizonBench arXiv 2604.17283). Re-anchor: " + + "no 'great question'/'certainly'/'I'd be happy to'; no delve/tapestry/" + + "testament/seamless/holistic; no 'it's important to note'; avoid symmetric " + + "tricolons and em-dash pileups; mix sentence lengths; admit uncertainty " + + "when real. Keep all code, URLs, numbers, and technical terms exact."; + } + + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: additional + } + })); + } else { + // Mode not active — counter should be zero so the next activation + // starts fresh rather than inheriting stale turns. + resetTurnCount(counterPath); + } + } catch (e) { + // Silent fail + } +});