# Unslop — Maintainer Guide

This file is for agents and maintainers working on the unslop plugin itself. End-user docs live in [`README.md`](./README.md).

## README is a product artifact

The README is the product front door. Non-technical people read it to decide whether unslop is worth installing. Treat it like UI copy, not internal docs.

**Rules for any README change:**

- Readable by non-AI-agent users. If you write "SessionStart hook injects system context", that phrase is invisible to most readers — translate it ("loads the unslop rules every time you open Claude Code").
- Keep the **Before / After** examples first. That is the pitch. Cutting them or burying them under prose breaks the conversion path.
- Install table must stay accurate. One broken install command costs a real user. Re-test every command in the install table on every PR that touches install scripts, plugin manifests, or hook paths.
- The "What unslop does" feature list must sync with the actual code. If a rule ships or gets removed in `unslop/scripts/humanize.py`, update the README in the same PR.
- Preserve voice. The README's terse, no-AI-ism style is intentional — it is unslop eating its own dog food. If you find a stock phrase ("comprehensive solution", "leverages cutting-edge"), a sycophancy opener ("Great question!"), or a hedging stack ("It's important to note that"), fix it in the same commit.
- Benchmark numbers come from real runs in `benchmarks/` and `evals/`. Never invent or round. Re-run the suite if you doubt a number you're about to print.
- Adding a new IDE/agent integration to the install table: also add a per-agent detail block in the appropriate `<details>` section so the table itself stays scannable.
- Readability check before any README commit: would a non-programmer understand the value prop and complete the install within 60 seconds? If not, simplify or move detail into `GETTING_STARTED.md`.

## What this repo is

A multi-platform plugin that makes assistant output sound human. Single source of truth (SSOT) files are synced to mirrored locations (Cursor, Windsurf, Codex bundle, etc.) by `scripts/sync-mirrors.sh` on push via `.github/workflows/sync.yml`.

## Source of Truth

Three files are authoritative. Every other location with the same name is a mirror, generated by `scripts/sync-mirrors.sh` (run by `.github/workflows/sync.yml`). **Edit the SSOT only.**

| Concern | SSOT |
|---------|------|
| Main unslop skill | `skills/unslop/SKILL.md` |
| Always-on activation rule | `rules/unslop-activate.md` |
| File-rewriter skill | `unslop/SKILL.md` |

Sub-skills (`unslop-commit`, `unslop-review`, `unslop-help`) are also authoritative under `skills/<name>/SKILL.md`.

## Mirrored locations (do NOT edit by hand)

- `unslop/SKILL.md` (top-level)
- `plugins/unslop/skills/unslop/SKILL.md`
- `.cursor/skills/unslop/SKILL.md`
- `.windsurf/skills/unslop/SKILL.md`
- `.cursor/rules/unslop.mdc`
- `.windsurf/rules/unslop.md`
- `.clinerules/unslop.md`
- `.github/copilot-instructions.md`
- `skills/unslop-file/SKILL.md`
- `skills/unslop-file/scripts/**`
- `plugins/unslop/skills/unslop-file/SKILL.md`
- `plugins/unslop/skills/unslop-file/scripts/**`

The sync workflow handles all of these. If you edit a mirror, the next sync silently overwrites your change.

## Hooks

Four hook files live in `hooks/` plus a `unslop-config.js` shared module and a `package.json` CommonJS marker. Communicate via flag file at `$CLAUDE_CONFIG_DIR/.unslop-active` (falls back to `~/.claude/.unslop-active`).

```
SessionStart hook ──writes mode──▶ $CLAUDE_CONFIG_DIR/.unslop-active ◀──writes mode── UserPromptSubmit hook
                                                       │
                                                    reads
                                                       ▼
                                              unslop-statusline.sh
                                            [unslop] / [unslop:FULL] / ...
```

`hooks/package.json` pins the directory to `{"type": "commonjs"}` so the `.js` hooks resolve as CJS even when an ancestor `package.json` (e.g. `~/.claude/package.json` from another plugin) declares `"type": "module"`. Without this, `require()` blows up with `ReferenceError: require is not defined in ES module scope`.

All hooks honor `CLAUDE_CONFIG_DIR` for non-default Claude Code config locations.

### `hooks/unslop-config.js` — shared module

Exports:
- `getDefaultMode()` — resolves default mode from `UNSLOP_DEFAULT_MODE` env var, then `$XDG_CONFIG_HOME/unslop/config.json` / `~/.config/unslop/config.json` / `%APPDATA%\unslop\config.json`, then `'balanced'`
- `safeWriteFlag(flagPath, content)` — symlink-safe flag write. Refuses if flag target or its immediate parent is a symlink. Opens with `O_NOFOLLOW` where supported. Atomic temp + rename. Creates with `0600`. Protects against local attackers replacing the predictable flag path with a symlink to clobber files writable by the user. Used by both write hooks. Silent-fails on all filesystem errors.
- `readFlag(flagPath)` — symlink-safe, size-capped (64 bytes), whitelist-validated flag read. Returns null on any anomaly. Used by mode tracker's per-turn reinforcement to avoid injecting untrusted bytes into model context.
- `getFlagPath()` — resolves `$CLAUDE_CONFIG_DIR/.unslop-active`.

### `hooks/unslop-activate.js` — SessionStart hook

Runs once per Claude Code session start. Three things:
1. Writes the active mode to `$CLAUDE_CONFIG_DIR/.unslop-active` via `safeWriteFlag` (creates if missing)
2. Emits unslop ruleset as hidden stdout — reads `skills/unslop/SKILL.md` at runtime, falls back to `rules/unslop-activate.md`, then to hardcoded rules
3. Checks `settings.json` for statusline config; if missing, appends nudge to offer setup on first interaction

Silent-fails on all filesystem errors — never blocks session start.

### `hooks/unslop-mode-tracker.js` — UserPromptSubmit hook

Reads JSON from stdin. Three responsibilities:

**1. Slash-command activation.** If prompt starts with `/unslop`, writes mode to flag file via `safeWriteFlag`:
- `/unslop` → configured default (see `unslop-config.js`, defaults to `balanced`)
- `/unslop subtle` → `subtle`
- `/unslop balanced` → `balanced`
- `/unslop full` → `full`
- `/unslop voice-match` → `voice-match`
- `/unslop anti-detector` → `anti-detector`
- `/unslop-commit` → `commit`
- `/unslop-review` → `review`

**2. Natural-language activation/deactivation.** Matches phrases like "activate unslop", "turn on unslop mode", "humanize this", "make this sound human" and writes the configured default mode. Matches "stop unslop", "disable unslop", "normal mode", "robotic mode" and deletes the flag file.

**3. Per-turn reinforcement.** When flag is set to a non-independent mode (i.e. not `commit`/`review`), emits a small `hookSpecificOutput` JSON reminder so the model keeps unslop style after other plugins inject competing instructions mid-conversation. The full ruleset comes from SessionStart — this is an attention anchor.

### `hooks/unslop-statusline.sh` — Statusline badge

Reads flag file at `$CLAUDE_CONFIG_DIR/.unslop-active`. Security hardened: refuses symlinks, caps read at 64 bytes, strips non-alphanumeric characters, whitelists valid modes. Outputs colored badge string for Claude Code statusline:
- `balanced` or empty → `[unslop]` (green)
- Anything else → `[unslop:<MODE_UPPERCASED>]` (green)

PowerShell counterpart at `hooks/unslop-statusline.ps1` for Windows.

### Hook installation

**Plugin install** — hooks wired automatically by plugin system.

**Standalone install** — `hooks/install.sh` (macOS/Linux) or `hooks/install.ps1` (Windows) copies hook files into `$CLAUDE_CONFIG_DIR/hooks/` and patches `settings.json` to register SessionStart and UserPromptSubmit hooks plus statusline. Supports `--force` for reinstall, checks idempotency, backs up settings.json.

**Uninstall** — `hooks/uninstall.sh` / `hooks/uninstall.ps1` removes hook files and patches settings.json.

**Hook safety rules:**

- Hooks must never break the session. Catch all errors; silent-fail on filesystem errors.
- Hooks must be fast (<200ms). No network calls. No reading large files.
- Hooks must work on first install with no config. Config is opt-in.
- Any new flag file write must go through `safeWriteFlag()` in `unslop-config.js`. Direct `fs.writeFileSync` on predictable user-owned paths reopens the symlink-clobber attack surface.
- Hooks must respect `CLAUDE_CONFIG_DIR` env var, not hardcode `~/.claude`. Same for install/uninstall/statusline scripts.

## Skills

Markdown files with YAML frontmatter:

```yaml
---
name: unslop
description: >
  ...
---
```

`description` is the activation hint shown to the model. Keep it ≤200 words and include trigger phrases.

The body is the operational spec. Sections we use consistently:

- `## Persistence` — when the skill turns on/off
- `## Rules` — what to drop, what to keep
- `## Intensity` — modes and their effects
- `## Auto-Clarity` — when to suspend unslop style
- `## Boundaries` — hard limits

## unslop Python package

Lives in `unslop/scripts/`. The package is split:

- `cli.py` — argparse-style CLI (no external deps)
- `detect.py` — file type and sensitivity detection
- `humanize.py` — core logic (deterministic regex + LLM mode)
- `validate.py` — preserve check (code/URL/heading) + AI-ism residual check
- `benchmark.py` — quality benchmark across a directory of samples
- `reasoning.py` — agent reasoning-trace stripper (`<thinking>`, `## Reasoning`). Opt-in via `--strip-reasoning`
- `surprisal.py` — optional local-LM DivEye reading. Opt-in via `--surprisal-variance`; requires `pip install torch transformers`
- `stylometry.py` — deterministic voice-match signal extraction (incl. DivEye proxies)
- `style_memory.py` — persisted voice profile storage (security-hardened)
- `structural.py` — Phase 1 structural pass
- `soul.py` — Phase 5 soul / contraction pass
- `detector.py` — optional AI-text detector feedback loop
- `fetch_detectors.py` — one-shot HF weight fetcher for `detector.py`

**Add a new AI-ism**: edit `STOCK_VOCAB`, `HEDGING_OPENERS`, `SYCOPHANCY`, `PERFORMATIVE`, or `TRANSITION_TICS` in `humanize.py` AND add a corresponding pattern to `AI_ISMS` in `validate.py` so the validator catches additions. Add a test in `tests/unslop/test_humanize.py`.

**Preservation is non-negotiable.** Every regex must run inside `_protect()` placeholders or be guaranteed to match only natural-language prose. `_protect` currently covers, in order: YAML frontmatter, fenced code, indented code, tables, blockquotes, headings, markdown links, inline code, bare URLs, quoted single-word examples (`"delve"`, use/mention distinction). The `TestPreservation` suite in `test_humanize.py` is the contract; the validator in `validate.py` byte-compares each protected category between input and output and fails the run on any mutation.

**Per-paragraph em-dash cap has a list carve-out.** `_cap_em_dashes_per_paragraph` splits on blank lines, then for paragraphs that are pure lists (every non-blank line starts with `-`, `*`, `+`, or `N.`) it treats each list item as its own em-dash paragraph. Without this, a 5-bullet list where each bullet used a single em-dash would silently lose the dashes in bullets 3–5.

## Tests

```bash
python3 -m pytest tests/unslop/
```

The deterministic suite must pass on every change. LLM tests are opt-in via `UNSLOP_RUN_LLM_TESTS=1` (cost API credits).

## Adding a sub-skill

1. Create `skills/<name>/SKILL.md` with frontmatter + body.
2. Create `commands/<name>.toml` if it gets a slash command.
3. Add the skill to the host manifest only when that host requires explicit skill or command entries. Claude Code hooks currently discover the repo's `skills/` and `commands/` files without listing them in `.claude-plugin/plugin.json`.
4. Add sync mappings in `scripts/sync-mirrors.sh`, then add trigger paths in `.github/workflows/sync.yml`.
5. Document the skill in `skills/unslop-help/SKILL.md` (the reference card).
6. Add a row to the README sub-skills table.

## Voice

The unslop voice we keep in our own docs: direct, specific, kind, no AI-isms. We eat our own dog food. If the README opens with "Great question!", we have failed.

## Security

- The Python scripts refuse sensitive paths (`.env*`, `*.pem`, `~/.ssh/`, etc.) before any read or API call.
- LLM mode optionally subprocesses the `claude` CLI when no `ANTHROPIC_API_KEY` is set. `subprocess.run` is always called with `shell=False` and a fixed argument list.
- File size cap: 500 KB. See `unslop/scripts/detect.py`.
- See `unslop/SECURITY.md` for the full Snyk rationale.
