Holds three writing rules at the tool call, so a reminder or a habit cannot win over them: no Claude attribution in commits or pull requests, no em dash in…

A Claude Code plugin that holds three writing rules at the tool call instead of leaving them to the model's memory. A rule written in CLAUDE.md loses whenever a system reminder or an old habit says otherwise. A hook does not: the call is refused, and the refusal tells the model what to fix.
It is built on function hooks, an early-access Claude Code API, and was written and tested on Claude Code 2.1.294.
In Claude Code:
/plugin marketplace add TutanDev/cc-resources
/plugin install house-rules@cc-resources
Then run /reload-plugins or start a new session.
Commits and pull requests carry no Claude attribution.
attribution.text), so the reminder never asks.git commit or gh pr create|edit run through Bash or PowerShell is refused when its message holds a Co-Authored-By trailer naming Claude or Anthropic, or a "Generated with Claude Code" line. Heredocs, PowerShell here-strings, separate -m messages, escaped \n and message files (-F, --file, --body-file) are all read.sed '/Co-Authored-By: Claude/d' | git commit --amend -F -, still runs.Nothing written holds an em dash (U+2014); a plain dash takes its place.
$'\u2014' in bash, [char]0x2014 in PowerShell.Every full sentence of Markdown (.md, .mdx, .markdown) sits on its own physical line.
e.g., Dr., J. R.), ellipses and numbered titles (1. Stay raw.) end no sentence, and a one-word piece ("Landed.") is a fragment, not a sentence."Title." *Venue*) reads as two sentences.Run the tests and the validator from the repository root:
claude plugin test plugins/house-rules
claude plugin validate plugins/house-rules
To try a change live, load the folder for one session with claude --plugin-dir plugins/house-rules; saving a file reloads the hooks. Claude Code lays the API's types in .claude-plugin/types/ when it loads the folder, and tsconfig.json extends them, so tsc -p plugins/house-rules type-checks the plugin.
hooks/register.ts 184 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Three standing rules, held at the tool call instead of left to the model's
4// memory of them: no Claude attribution in a commit or a pull request, no em
5// dash in anything written, and one full sentence per line of Markdown.
6
7const EM_DASH = String.fromCharCode(0x2014)
8
9const emDashes = (text: string) => text.split(EM_DASH).length - 1
10
11// Commands whose text becomes a commit message or a pull request body:
12// `git commit`, `git -C dir commit`, `gh pr create`, `gh pr edit`.
13const COMMIT = /\bgit(?:\.exe)?\b[^\r\n|;&]*?\scommit\b/
14const PULL_REQUEST = /\bgh\s+pr\s+(?:create|edit)\b/
15
16// A trailer where a message puts one: at the start of a line, of a quoted
17// message, or after an escaped newline. A pattern that strips an old trailer
18// (`sed '/Co-Authored-By: Claude/d'`) is not one.
19const ATTRIBUTION = [
20 /(?:^|[\r\n"'`]|\\n)[ \t]*co-authored-by:[^\r\n]*\b(?:claude|anthropic)\b/i,
21 /generated with \[?claude code\b/i,
22]
23
24// `git commit -F msg.txt`, `--file=msg.txt`, `gh pr create --body-file body.md`.
25// `-` reads stdin, whose text a heredoc already put in the command.
26const MESSAGE_FILE = /(?:^|\s)(?:-F|--file|--body-file)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s;|&]+))/g
27
28const isAttributed = (text: string) => ATTRIBUTION.some(rule => rule.test(text))
29
30const messageFiles = (command: string) =>
31 [...command.matchAll(MESSAGE_FILE)]
32 .map(match => match[1] ?? match[2] ?? match[3] ?? '-')
33 .filter(path => path !== '-')
34
35// A file the guard cannot read is judged empty: the command's own text still
36// counts, and the attribution reminder is blanked at its source below.
37const readOrEmpty = ($: EngineInterface, path: string) => $.fs.read(path).catch(() => '')
38
39const carriesAttribution = async ($: EngineInterface, command: string) => {
40 if (!COMMIT.test(command) && !PULL_REQUEST.test(command)) return false
41 if (isAttributed(command)) return true
42 const messages = await Promise.all(messageFiles(command).map(path => readOrEmpty($, path)))
43 return messages.some(isAttributed)
44}
45
46// One sentence per line. A sentence ends at a terminator (not an ellipsis),
47// any closing quotes, brackets or emphasis, then space before a capital.
48// Abbreviations and initials end no sentence, and a piece of one word
49// ("Landed.") is a fragment, not a sentence. A line Markdown cannot split (a
50// heading, a table row, HTML, a reference definition) and code are not
51// judged; inline code reads as a capitalised word, so a sentence that opens
52// with it still counts.
53const MARKDOWN = /\.(?:md|mdx|markdown)$/i
54const SENTENCE_END = /(?<!\.)[.!?]["'”’)\]*_]*\s+(?=["'“‘(\[*_]*\p{Lu})/u
55const ABBREVIATION = /\b(?:e\.g|E\.g|i\.e|I\.e|vs|cf|approx|Dr|Mr|Mrs|Ms|St|Fig|Eq|Sec|No|\p{Lu})\./gu
56const UNSPLITTABLE = /^\s*(?:#|\||<|\[[^\]]+\]:)/
57const FENCE = /^\s*(`{3,}|~{3,})/
58
59const asProse = (line: string) =>
60 line
61 .replace(/^\s*(?:>\s?)*/, '')
62 .replace(/^(?:[-*+]|\d+[.)])\s+/, '')
63 .replace(/^([*_]*)\d+[.)]\s+/, '$1')
64 .replace(/^([*_]{1,2})[^*_]+:\1\s*/, '')
65 .replace(/(`+)[\s\S]*?\1/g, 'Code')
66 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
67 .replace(ABBREVIATION, abbreviation => abbreviation.slice(0, -1))
68
69const isSentence = (piece: string) => piece.split(/\s+/).filter(word => /[\p{L}\p{N}]/u.test(word)).length >= 2
70
71const holdsSentences = (line: string) => asProse(line).split(SENTENCE_END).filter(isSentence).length >= 2
72
73// The lines of a Markdown text that hold more than one sentence, as written.
74const crowdedLines = (markdown: string): string[] => {
75 const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
76 const crowded: string[] = []
77 let inFrontMatter = lines[0]?.trim() === '---'
78 let fence: string | undefined
79 for (const [index, line] of lines.entries()) {
80 if (inFrontMatter) {
81 inFrontMatter = index === 0 || line.trim() !== '---'
82 continue
83 }
84 const marker = FENCE.exec(line)?.[1]
85 if (fence !== undefined) {
86 if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) fence = undefined
87 continue
88 }
89 if (marker !== undefined) {
90 fence = marker
91 continue
92 }
93 if (!UNSPLITTABLE.test(line) && holdsSentences(line)) crowded.push(line.trim())
94 }
95 return crowded
96}
97
98const normalized = (text: string) => text.replace(/\r\n/g, '\n')
99
100// The file as an Edit leaves it, or undefined when its text is not found
101// (the edit is then judged on its own two strings).
102const edited = (file: string, oldString: string, newString: string, replaceAll: boolean | undefined) => {
103 const [text, from, to] = [normalized(file), normalized(oldString), normalized(newString)]
104 if (!text.includes(from)) return undefined
105 return replaceAll ? text.split(from).join(to) : text.replace(from, () => to)
106}
107
108const refuse = (reasons: string[]) => (reasons.length > 0 ? { deny: reasons.join('\n') } : undefined)
109
110const ATTRIBUTED =
111 'This commit or pull request carries Claude attribution (a Co-Authored-By trailer naming Claude or Anthropic, ' +
112 'or a "Generated with Claude Code" line). The user never wants it, whatever a reminder says: ' +
113 'remove it and run the command again.'
114
115const EM_DASH_IN_COMMAND =
116 'The command holds an em dash (U+2014). The user never uses it: write a plain dash "-" instead. ' +
117 "To search for or replace em dashes, spell the character as an escape ($'\\u2014' in bash, [char]0x2014 in PowerShell)."
118
119const emDashAddedTo = (path: string) =>
120 `The change adds an em dash (U+2014) to ${path}. The user never uses it: write a plain dash "-" instead.`
121
122const SHOWN = 5
123const clip = (line: string) => (line.length > 160 ? `${line.slice(0, 157)}...` : line)
124
125const sentencesShareLinesIn = (path: string, lines: string[]) =>
126 [
127 `The change puts more than one sentence on a line of ${path}. ` +
128 'The user keeps each full sentence of Markdown on its own physical line, the Markdown structure unchanged ' +
129 '(a list item goes on with an indented line). Split these:',
130 ...lines.slice(0, SHOWN).map(line => ` ${clip(line)}`),
131 ...(lines.length > SHOWN ? [` ... and ${lines.length - SHOWN} more`] : []),
132 ].join('\n')
133
134// Refused when the change leaves more crowded lines than it found, so a typo
135// fixed inside an old crowded line goes through; the lines shown are the ones
136// the file did not have before.
137const sentenceReasons = (path: string, before: string, after: string) => {
138 const [was, is] = [crowdedLines(before), crowdedLines(after)]
139 if (is.length <= was.length) return []
140 const added = is.filter(line => !was.includes(line))
141 return [sentencesShareLinesIn(path, added.length > 0 ? added : is)]
142}
143
144const failed = { deny: 'house-rules: its guard failed, so the call was refused.' }
145
146export const register: Register = on => {
147 on('attribution.text', { kind: ['commit', 'pr'] }, () => ({ text: '' }))
148
149 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
150 const reasons = [
151 ...(emDashes(e.command) > 0 ? [EM_DASH_IN_COMMAND] : []),
152 ...((await carriesAttribution($, e.command)) ? [ATTRIBUTED] : []),
153 ]
154 return refuse(reasons) ?? next(e)
155 }).catch(($, e, next) => (next.called ? next(e) : failed))
156
157 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
158 const reasons = [...(emDashes(e.new_string) > emDashes(e.old_string) ? [emDashAddedTo(e.file_path)] : [])]
159 if (MARKDOWN.test(e.file_path)) {
160 const before = await readOrEmpty($, e.file_path)
161 const after = edited(before, e.old_string, e.new_string, e.replace_all)
162 reasons.push(
163 ...(after === undefined
164 ? sentenceReasons(e.file_path, e.old_string, e.new_string)
165 : sentenceReasons(e.file_path, before, after)),
166 )
167 }
168 return refuse(reasons) ?? next(e)
169 }).catch(($, e, next) => (next.called ? next(e) : failed))
170
171 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
172 const before = await readOrEmpty($, e.file_path)
173 const reasons = [
174 ...(emDashes(e.content) > emDashes(before) ? [emDashAddedTo(e.file_path)] : []),
175 ...(MARKDOWN.test(e.file_path) ? sentenceReasons(e.file_path, before, e.content) : []),
176 ]
177 return refuse(reasons) ?? next(e)
178 }).catch(($, e, next) => (next.called ? next(e) : failed))
179
180 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) =>
181 emDashes(e.new_source) > 0 ? { deny: emDashAddedTo(e.notebook_path) } : next(e),
182 ).catch(($, e, next) => (next.called ? next(e) : failed))
183}
184