Rule Promoter
Overview
CLAUDE.md rules are advice: Claude can rationalize past them. A hook is enforcement. This skill reads the project's CLAUDE.md, picks out the rules a hook can actually enforce, writes them into a rules file enforced by a small tested engine, proves each rule blocks a violation, and installs the hooks after the user confirms. It runs only when the user asks. Hooks are guardrails, not a security boundary: a determined workaround (a helper script, sh -c) can get past a pattern. Say so.
Files (in this skill's base directory, shown when the skill loads)
scripts/rule_hook.py: the engine. Copy it to.claude/hooks/rule_hook.pyin the project.python rule_hook.py selftestproves every rule;checkis the hook entry point.scripts/settings_merge.py:launcherpicks a Python command that works;planshows the settings diff;applywrites it.
Run both with a Python 3 command that works on this machine (try python3, python, py -3). Quote paths.
Process
-
Read. Find
CLAUDE.md,.claude/CLAUDE.mdandCLAUDE.local.mdin the project. List every rule with its file and line. If there is none, say so and stop. -
Classify each rule into exactly one of:
- an engine type (below) with concrete parameters;
taste, with a one-line reason;needs input, with the specific question to ask (for example "which command runs your tests?").
A rule is enforceable only if it names concrete tools, paths, commands or text patterns, and a violation is detectable from one tool call or from an end-of-turn command's exit status. When unsure, mark it
needs inputand ask; never guess a pattern for a vague rule. Never invent a rule type. -
Review. Show a table: rule, type, pattern or command, a sample violation, a sample pass, and the false-positive risk. Show every
stop_checkcommand verbatim. Ask the user to drop or adjust rows. Write nothing yet. -
Prove. After approval, create
.claude/hooks/and copyscripts/rule_hook.pythere, write.claude/rules.json, then runpython .claude/hooks/rule_hook.py selftestfrom the project root. Show the output. A rule whose proof fails is not installed: fix its pattern or drop it, and say which one. -
Install. Run
settings_merge.py launcher --script .claude/hooks/rule_hook.py, thensettings_merge.py plan --settings .claude/settings.json --launcher "<launcher>" [--pretool] [--stop](--pretoolif any path, command or content rules exist;--stopif any stop_check rules exist). Show the diff and ask for a yes. Only after a yes, run the same command withapply. If it reports invalid settings JSON or a duplicate key, stop and tell the user; do not edit the file by hand. The helper removes only its ownrule_hook.pyentries and leaves every other hook alone. If the end-to-end check below shows the hook did not fire althoughselftestpasses anddisableAllHooksis not set, some Claude Code versions lack exec-formargs: re-runplanandapplywith--shell-form. -
Verify end to end. If you can run
claude -pin the project, test with aprotected_pathorbanned_contentrule against a throwaway file (for example ask it to edittmp-rule-check.txtunder a protected glob), never with ablocked_commandorstop_checkthat would really run something, and undo any change afterwards. CheckdisableAllHooksin the project, local and user settings before concluding anything. Confirm the output shows theRule <id>:block message, and show it. If you cannot run a nested session, say so and say the install is verified only byselftest. -
Report. List the promoted rules, the skipped rules with reasons, and one limitation: Claude can still edit
.claude/rules.json,.claude/hooks/and.claude/settings.json. Offer to add aprotected_pathrule for them, and say it also blocks re-running this skill until the rule is disabled. Then say how to turn things off: set"enabled": falseon a rule in.claude/rules.json, or delete therule_hook.pyentries from.claude/settings.json.
Never write rules.json, hook files or settings.json before the user has reviewed the table. Never write settings.json before showing the diff and getting a yes.
If the user adds a rule or changes a pattern after the table, or you have to change a pattern because it is invalid or its proof fails, show that new or changed row (pattern, sample violation, sample pass, risk) and wait for a yes before writing anything. An approval given earlier does not cover a different pattern, and never substitute a pattern the user did not see.
Rule types
Every rule has id (kebab-case), source (CLAUDE.md:14), text (the original rule), message (shown to Claude when blocked), type, the fields below, and a proof with a violation and a pass payload.
protected_path: no edits to matching files. Globs match the whole project-relative path; use **/name for any depth.
{"id": "no-migration-edits", "source": "CLAUDE.md:3", "text": "Never edit anything under migrations/",
"type": "protected_path", "globs": ["**/migrations/**"], "allow_globs": [],
"message": "Migrations are generated. Create a new migration instead.",
"proof": {"violation": {"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "app/migrations/0001_initial.py", "old_string": "a", "new_string": "b"}},
"pass": {"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "app/models.py", "old_string": "a", "new_string": "b"}}}}
blocked_command: Bash commands matching a regular expression, tested against each segment of a compound command. Use except_patterns for safe variants; an exception exempts the whole segment, so make it specific.
{"id": "no-force-push", "source": "CLAUDE.md:4", "text": "Never run git push --force",
"type": "blocked_command", "patterns": ["\\bgit\\s+push\\b.*(?:--force\\b|\\s-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+\\w)"], "except_patterns": ["--force-with-lease"],
"message": "Force pushes are not allowed.",
"proof": {"violation": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}},
"pass": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "git push origin feature"}}}}
banned_content: regular expressions that must not appear in the new text being written (Edit, Write, MultiEdit, NotebookEdit), optionally limited by file globs. Old text is never inspected.
{"id": "no-console-log", "source": "CLAUDE.md:5", "text": "No console.log( in src/",
"type": "banned_content", "patterns": ["console\\.log\\("], "globs": ["src/**"],
"message": "Use the logger, not console.log.",
"proof": {"violation": {"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "src/a.js", "content": "console.log(1)"}},
"pass": {"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "src/a.js", "content": "logger.info(1)"}}}}
stop_check: a command that must succeed before Claude may stop. It blocks the stop when the command exits non-zero. when_changed_globs limits it to turns that changed matching files (uncommitted changes only: omit it if Claude tends to commit before stopping). The proof uses simulate_exit (selftest does not run the real command).
{"id": "tests-pass", "source": "CLAUDE.md:6", "text": "Run npm test before you finish",
"type": "stop_check", "command": "npm test", "timeout_seconds": 240, "when_changed_globs": ["src/**", "tests/**"],
"message": "The tests must pass before you stop.",
"proof": {"violation": {"hook_event_name": "Stop", "simulate_exit": 1}, "pass": {"hook_event_name": "Stop", "simulate_exit": 0}}}
The file is {"version": 1, "rules": [ ... ]}. Tool payloads: Bash uses tool_input.command; Edit uses file_path, old_string, new_string; Write uses file_path, content.
What is enforceable (classification guide)
- Enforceable: "never edit X/", "don't touch .env", "never run Y", "don't use Z in files under W", "run T before finishing".
- Taste: "prefer small functions", "write clear commit messages", "keep the UI accessible", "favor clarity". No pattern can decide these.
- Needs input: "be careful with the database", "make sure tests pass" (which command?), "avoid legacy code" (which paths?). Ask; do not guess.
- Globs support only
*,**and?, relative to the project (no[ab],{a,b}, leading./or/); the engine refuses anything else.timeout_secondsis at most 280, andenabledmust betrueorfalse. - Prefer narrow patterns. State the false-positive risk for each rule (for example a
mainpattern also matches a branch namedmain-menu) and addallow_globsorexcept_patternswhen that matters.
Tone
Brief and factual. This is setup work, not a lecture about rules.
Common Mistakes
- Writing any file, or settings, before the user has reviewed the table.
- Writing
settings.jsonwithout showing the diff and getting a yes. - Installing a rule the user has not seen as a table row, or quietly replacing the pattern the user asked for.
- Installing a rule whose proof did not pass.
- Promoting a taste rule, or guessing a pattern for a vague rule.
- Inventing a rule type the engine does not have.
- Leaving out the false-positive risk, or the "guardrail, not a security boundary" caveat.
- Editing an invalid
settings.jsonby hand instead of stopping. - Claiming an end-to-end block you did not actually observe.