A Claude Code plugin that flags algebraic blindness — types too generic to
carry the meaning of their own values (bool, bare string/int, blind
Maybe/Option/null, untagged tuples) — in code Claude writes, and suggests
named-type fixes. It suggests, it does not auto-edit: findings come back as
text and Claude makes (or declines) the change.
Algebraic blindness is semantic: whether a bool or a Maybe is fine or a bug
depends on what it means in your domain — knowledge a regex doesn't have. So the
plugin is two-stage:
- Stage 1 — regex prefilter (
lib.sh: ub_prefilter): catches the syntactic symptom shapes cheaply. Loose by design (recall > precision); skips the LLM on edits with no symptom at all. - Stage 2 — LLM judge (
lib.sh: ub_judge→claude -p): applies the checklist and the "when to stop" guardrails to separate real blindness from a self-evident boolean / YAGNI variant / harmless newtype.
The shared rubric lives once in skills/spot-blindness/checklist.md and is used
both as the judge's prompt and as Claude's remediation reference (via the
spot-blindness skill).
The judge invokes claude -p with --strict-mcp-config --mcp-config '{"mcpServers":{}}', so it loads zero MCP servers — the one-shot call stays
cheap and never spins up your configured MCP servers (Grafana, Playwright, etc.).
It still reuses your session login. (--bare would skip even more but drops the
login, so it is intentionally not used.)
python3(orpython) — used for JSON parsing/serialization. Nojqdependency; Python ships with macOS and virtually every Linux dev box.- the
claudeCLI onPATH(the judge runsclaude -p, reusing your existing login — no separate API key needed) gitis optional; used for accurate per-turn diffs when present
This repo is both the plugin and its marketplace, so installing is two commands:
/plugin marketplace add nikicat/unblind
/plugin install unblind@unblind
Then /reload-plugins (or restart). To update later: /plugin marketplace update unblind.
Installs are pinned to a release tag, not master. The marketplace entry's
plugin source points at a GitHub ref (currently v0.1.0), so pushing to
master does not change what installed users run — only cutting a new tag
and bumping the ref does. The marketplace catalog is read from master; the
plugin code is fetched from the tag.
To cut a release vX.Y.Z:
- Bump
versionin.claude-plugin/plugin.json(and the marketplace entry). - Commit, then tag and push the tag:
git tag vX.Y.Z && git push origin vX.Y.Z. - Set
refin.claude-plugin/marketplace.jsontovX.Y.Z, commit, pushmaster. gh release create vX.Y.Z --generate-notes.
Users pick it up on /plugin marketplace update unblind.
For local development the github-pinned source means a local marketplace add
still fetches from the tag, not your working copy. To iterate locally, run the
test suite (below), or temporarily set the plugin source to "./".
hooks/hooks.json wires:
UserPromptSubmit→baseline.sh— records pre-turngit HEADso the diff survives commits made during the turn.PostToolUse→accumulate.sh— records changed files (non-git fallback).Stop→stop-review.sh— once per turn, diffs the change, runs the two stages, and if it finds blindness hands suggestions back via{"decision":"block"}so Claude fixes or confirms intentional. Self-dedupes so it never traps the turn.SessionEnd→cleanup.sh.
This is the recommended default: full-file context, one LLM pass per turn, and it uses only well-documented hook mechanics.
hooks/hooks.async.json wires a single PostToolUse hook (detect.sh,
asyncRewake: true) that runs in the background per edit and re-wakes Claude with
a suggestion when it finds something. Lower latency to feedback, but noisier and
asyncRewake on PostToolUse is experimental — verify it actually re-wakes in
your version before relying on it. To switch:
cp hooks/hooks.async.json hooks/hooks.json # then /reload-plugins
UNBLIND_MODEL— judge model (defaultclaude-haiku-4-5-20251001).UNBLIND_MAX_CTX— chars of file/diff context sent to the judge (default 12000).UNBLIND_WINDOW— when a file exceedsMAX_CTX, lines of context kept around each symptom line (default 40). Ensures a smell buried deep in a large file still reaches the judge instead of being truncated off the top.- Source extensions: edit
ub_is_sourceinhooks/scripts/lib.sh. - Symptom patterns: edit
ub_prefilterinhooks/scripts/lib.sh(keep it loose). - False positives: tighten the prompt in
ub_judge/ the "when to stop" section ofchecklist.md, not the regex.
All temp state lives under $CLAUDE_PLUGIN_DATA keyed by session_id
(baseline-<sid>, changed-<sid>.txt, reported-<sid>), so concurrent sessions
on the same project don't collide. cleanup.sh removes a session's files on
SessionEnd and sweeps orphans older than a day.
tests/prefilter_test.sh # offline, no creds: Stage-1 recall on positive fixtures
tests/run.sh # end-to-end: drives detect.sh + `claude -p` over fixtures
tests/run.sh positive # only positives | tests/run.sh negative # only negatives
Fixtures live in tests/fixtures/{positive,negative}/. Positives must produce a
SMELL (exit 2); negatives must come back CLEAN (exit 0). The negatives include the
tricky over-narrowing cases (self_evident_bool.rs, harmless_single_bool.ts)
that deliberately trip the prefilter but must be judged CLEAN — they exercise the
"when to stop" guardrails. run.sh calls the real model, so judgments can rarely
flake; the fixtures are chosen to be clear-cut.
- The judge is an LLM: it will occasionally miss or over-flag. It's an assistant, not a gate.
- Per-edit context is partial; the
Stopvariant judges on full files and is more accurate. - Go (and other languages without sum types) gets weaker structural fixes by
design — see the note in
checklist.md.