Roadmap
May 11, 2026 · View on GitHub
The shape of the next refactor, driven by the first round of substantive external feedback (@Tekalan in anthropics/claude-code#57661).
This document is the canonical spec for the loadable-packs architecture that several open issues depend on. Issues link back here.
North-star: zero forking required
The current shape inlines vocabulary (closeout verbs, evidence binaries, destructive commands) into every hook script's regex literals. That works for the smallest demo case but breaks down the moment an operator is:
- Working in a non-English language (Polish, Spanish, German, French, ...)
- Working on infra/devops (docker, kubectl, terraform, ansible, helm, ...)
- Operating a load-bearing service that has its own destructive vocabulary (Home Assistant config dirs, k8s namespaces, prod databases, ...)
Operators are forking and editing the regex by hand. That is the wrong architecture. The loadable-packs design moves all locale-specific, toolchain-specific, and surface-specific data out of the hook scripts and into flat files the operator (or contributor) can extend without touching bash.
Pack types
Three pack types, each with the same shape:
packs/
locale/ # vocabulary for matching model output
en.txt # current English vocab, extracted
es.txt # Spanish
pl.txt # Polish (per Tekalan's report)
de.txt # German
fr.txt # French
pt.txt # Portuguese
...
evidence/ # binaries that count as command evidence
binaries.txt # one binary per line, grouped by section
destructive/ # destructive command surfaces
filesystem.txt # current behavior (recursive deletion etc.)
container.txt # docker / kubectl destruction
git-protected.txt # force-push to main, filter-branch, etc.
config-overwrite.txt # in-place edits against live config dirs
cloud-prod.txt # cloud-provider destruction
database.txt # DROP / TRUNCATE / FLUSHALL
service.txt # systemctl stop critical services
Pack file format
Plain text, one entry per line, blank lines and #-comments allowed.
Section headers in [brackets] for grouping when relevant.
# packs/locale/es.txt
# Spanish vocabulary for the LLM Dark Patterns hooks suite.
# Maintained by community contributors.
[positive_closeout]
listo
hecho
implementado
terminado
completado
funciona
[failed_verification]
no probado
sin verificar
falló la verificación
[evidence]
verificación: pasada
ejecutado:
salida:
# packs/evidence/binaries.txt
# Binaries that count as command evidence in `has_command_evidence`.
[app-dev]
bash
git
npm
pnpm
yarn
pytest
python3
ruff
cargo
go
make
[devops]
docker
kubectl
terraform
ansible
helm
nomad
consul
vault
[shell-tools]
jq
sed
awk
rsync
find
grep
[system]
systemctl
journalctl
lsblk
ip
iptables
[cloud]
aws
gcloud
az
[database]
psql
mysql
redis-cli
mongosh
Pack loading
Hooks load packs once per invocation, in priority order:
LLM_DARK_PATTERNS_PACK_DIRenv var → directory containing override packs~/.config/llm-dark-patterns/packs/→ operator-local packs (gitignored)- The repo's
packs/directory → defaults that ship with the suite
Operator opts in/out of categories via env:
LLM_DARK_PATTERNS_LOCALE=en,es,pl→ which locale packs to loadLLM_DARK_PATTERNS_EVIDENCE_CATEGORIES=app-dev,devops,k8s→ subset of binariesLLM_DARK_PATTERNS_DESTRUCTIVE_PACKS=filesystem,container,git-protected→ which surfaces
Auto-detect locale from LANG/LC_ALL if LLM_DARK_PATTERNS_LOCALE unset.
Default to en if both unset.
Hook script changes
Each hook gains a load_pack() helper at the top:
load_pack() {
local kind="\$1" # locale | evidence | destructive
local name="\$2" # e.g. "es", "binaries", "container"
for dir in \
"${LLM_DARK_PATTERNS_PACK_DIR:-}" \
"${HOME}/.config/llm-dark-patterns/packs" \
"$(dirname "\$0")/../packs" ; do
[ -z "$dir" ] && continue
local file="${dir}/${kind}/${name}.txt"
if [ -f "$file" ]; then
grep -Ev '^[[:space:]]*(#|$)' "$file"
return 0
fi
done
return 1
}
Hook regexes are built from the loaded pack lines (joined with | for
alternation). The result is the same kind of regex that's currently
hardcoded — only the source moves out of the script.
Migration plan
The refactor preserves existing behavior. No fixture should regress.
Phased rollout:
- Phase 1 — extract the existing English vocab into
packs/locale/en.txt. Hook scripts loaden.txtinstead of inlining. All 168 stress fixtures continue to pass. - Phase 2 — add second locale. Ship
packs/locale/es.txt(Spanish, big community) and add Spanish stress fixtures alongside the English ones. - Phase 3 — externalize evidence binaries into
packs/evidence/binaries.txtwith section headers. Add devops/cloud/database/system fixtures. - Phase 4 — externalize destructive commands into surface-specific packs. Add per-surface fixtures.
- Phase 5 — hardening for the two bypasses (#4,
#5) — proximity
bound on
has_command_evidence, clause-local negation inhas_positive_closeout. New stress fixtures for both.
Each phase is its own PR with its own stress fixtures and verification.
Open issues this roadmap addresses
- #1 i18n locale packs (Phase 1, 2)
- #2 External evidence binary allowlist (Phase 3)
- #3 External destructive command packs (Phase 4)
- #4
has_command_evidenceproximity bypass (Phase 5) - #5
has_positive_closeoutnegation early-return bypass (Phase 5)
Non-goals (explicitly)
- Writing a YAML/JSON pack format. Plain text with section headers is enough and stays diff-friendly.
- Replacing
bash + jqwith a different runtime. The out-of-band, non-LLM judge is the design point of the suite. - Per-binary regex sophistication. Substring/word match is enough; binaries in backticks are unambiguous.
- LLM-based pack generation or maintenance. Packs are human-edited; community PRs add new locales/surfaces. The model never decides what vocabulary the model gets judged against.
How to contribute a new pack
- Open a PR adding
packs/<kind>/<name>.txt - Add at least one positive stress fixture under
tests/stress/<hook>/positive/that exercises the new pack - Update
tests/stress/_gen_fixtures.pyif the new fixture follows a regular pattern, or commit the JSON directly - Verify locally:
bash tests/stress/run.sh - README update in the same PR with the new pack listed