SLOPSHOPPER

House Rules

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…

newguard
v0.1.0no licenseupdated 2026-10-09TutanDev/cc-resources/plugins/house-rules
A shopper browsing a rack in a slop shop
README

house-rules

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.

Install

In Claude Code:

/plugin marketplace add TutanDev/cc-resources
/plugin install house-rules@cc-resources

Then run /reload-plugins or start a new session.

The Rules

No Claude Attribution

Commits and pull requests carry no Claude attribution.

  • The commit trailer and the pull request footer that Claude Code asks the model to add are blanked at their source (attribution.text), so the reminder never asks.
  • As a backstop, a 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.
  • A command that searches for or strips an old trailer, such as sed '/Co-Authored-By: Claude/d' | git commit --amend -F -, still runs.

No Em Dash

Nothing written holds an em dash (U+2014); a plain dash takes its place.

  • An Edit is refused when its new text has more em dashes than the text it replaces, and a Write when its content has more than the file it overwrites. A file that already holds some can still be edited.
  • A NotebookEdit is refused for any em dash.
  • A Bash or PowerShell command is refused for any literal em dash. To search for or replace em dashes, spell the character as an escape: $'\u2014' in bash, [char]0x2014 in PowerShell.

One Sentence per Line

Every full sentence of Markdown (.md, .mdx, .markdown) sits on its own physical line.

  • A Write or Edit is judged on the whole file as the change leaves it, and refused only when the file ends up with more crowded lines than it had. An old file that breaks the rule can still be edited, and a typo fixed inside an old crowded line goes through.
  • The refusal names the lines to split.
  • Front matter, fenced code, headings, table rows, HTML and reference definitions are not judged, because Markdown cannot split them.
  • Abbreviations and initials (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.

Limits

  • Files written through MCP tools, and Markdown written through a shell heredoc, are not checked.
  • A citation with a quoted title ("Title." *Venue*) reads as two sentences.
  • A message file the guard cannot read, such as a relative path after the shell changed directory, is judged on the command's text alone.

Developing

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.

Source 1 files
hooks/register.ts 184 lines
1import 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