House rules for every session: no em dash, person-only asks for risky commands, and no weakened tests.

A Claude Code setup for fullstack and devops work; this repo is ~/.claude itself.
The setup loads two mods: house, kept here, and workflow, a git submodule pointing at claude-workflow, which can also be installed on its own as a plugin.
On a new machine:
git clone --recurse-submodules git@github.com:ilianusss/.claude.git ~/.claude
On a machine that already has a ~/.claude, quit every Claude Code session, then replace the folder and bring back the runtime data (memory, transcripts, history, plugins, themes):
mv ~/.claude ~/.claude.old
git clone --recurse-submodules git@github.com:ilianusss/.claude.git ~/.claude
rsync -a --ignore-existing \
--exclude /skills/ --exclude /CLAUDE.md --exclude /RTK.md \
--exclude '/settings.json*' --exclude /dev-mods/ \
~/.claude.old/ ~/.claude/
The clones use SSH so changes to the setup can be pushed back; the submodule follows the same protocol. Old skills are left behind on purpose. Keep ~/.claude.old until a week of use shows nothing is missing. Then start a session in a repo and type /workflow: an answer means the mods loaded.
The daily commands are /workflow and its subcommands, described in docs/workflow.md.
CLAUDE.md instructions for every session
settings.json permissions, sandbox, the rtk hook, the mods to load
rules/ guidance loaded when Claude touches a matching file
skills/ knowledge Claude loads on demand
agents/ the reviewer, run in a fresh context
mods/house/ every session: em dashes, safety asks, weakened tests
mods/workflow/ the /workflow mod (submodule)
docs/ how it works and how to change it
Everything else in ~/.claude is Claude Code's runtime data, ignored by git.
/workflow.The swap leaves these untouched:
~/.claude.json: claude mcp list shows them./plugin.hooks/register.ts 12 lines1import type { Register } from 'claude-code'
2
3import { registerIntegrity } from '../src/integrity'
4import { registerSafety } from '../src/safety'
5import { registerTypography } from '../src/typography'
6
7export const register: Register = on => {
8 registerTypography(on)
9 registerSafety(on)
10 registerIntegrity(on)
11}
12src/integrity.ts 178 lines1import type { EngineInterface, On, ToolCallResult } from 'claude-code'
2
3import { matchesAny } from './glob'
4
5const DEFAULT_TEST_GLOBS = [
6 '**/*.{test,spec}.*',
7 '**/__tests__/**',
8 '**/tests/**',
9 '**/test/**',
10 '**/spec/**',
11 '**/e2e/**',
12 '**/*_test.go',
13 '**/test_*.py',
14 '**/*_test.py',
15]
16const SNAPSHOT_GLOBS = ['**/__snapshots__/**', '**/*.snap']
17
18const MARKERS: ReadonlyArray<readonly [label: string, pattern: RegExp]> = [
19 ['.only', /\.only\s*\(/g],
20 ['.skip', /(?<!mark|pytest)\.skip\s*\(/g],
21 ['xit/xdescribe/xtest', /\bx(?:it|describe|test)\s*\(/g],
22 ['fit/fdescribe', /\bf(?:it|describe)\s*\(/g],
23 ['a pytest skip or xfail', /@pytest\.mark\.(?:skip|skipif|xfail)\b|\bpytest\.skip\s*\(/g],
24 ['a unittest skip', /@unittest\.skip|\bself\.skipTest\s*\(/g],
25 ['t.Skip', /\bt\.Skip(?:f|Now)?\s*\(/g],
26 ['#[ignore]', /#\[ignore\]/g],
27]
28const ASSERTIONS =
29 /\bexpect\s*\(|\bassert[A-Za-z_]*\b|\bt\.(?:Error|Errorf|Fatal|Fatalf)\s*\(|\brequire\.[A-Z]\w*\s*\(|\.should\b|\b(?:TEST_ASSERT|ASSERT|EXPECT)_\w+/g
30
31const RULE =
32 'The tests rule forbids weakening a test: revert it, or keep it only if the user agreed, and list it in your report with the reason.'
33
34type SessionState = {
35 cwd: string
36 root: string | undefined
37 globs: string[]
38 reported: Set<string>
39}
40
41function count(text: string, pattern: RegExp): number {
42 return text.match(pattern)?.length ?? 0
43}
44
45function lines(text: string): string[] {
46 return text
47 .split('\n')
48 .map(line => line.trim())
49 .filter(line => line.length > 0)
50}
51
52function plural(count: number, noun: string): string {
53 return `${count} ${noun}${count === 1 ? '' : 's'}`
54}
55
56export function weakenings(before: string, after: string): string[] {
57 const found = MARKERS.filter(([, pattern]) => count(after, pattern) > count(before, pattern)).map(
58 ([label]) => `added ${label}`,
59 )
60 const removed = count(before, ASSERTIONS) - count(after, ASSERTIONS)
61 if (removed > 0) return [...found, `removed ${plural(removed, 'assertion')}`]
62 const kept = new Set(lines(after))
63 const changed = lines(before).filter(line => count(line, ASSERTIONS) > 0 && !kept.has(line)).length
64 return changed > 0 ? [...found, `changed ${plural(changed, 'existing assertion')}`] : found
65}
66
67export function changesByFile(diff: string): Map<string, { removed: string; added: string }> {
68 const files = new Map<string, { removed: string[]; added: string[] }>()
69 let current: { removed: string[]; added: string[] } | undefined
70 let removedFrom: string | undefined
71 for (const line of diff.split('\n')) {
72 if (line.startsWith('--- ')) {
73 removedFrom = line.startsWith('--- a/') ? line.slice(6) : undefined
74 } else if (line.startsWith('+++ ')) {
75 const path = line.startsWith('+++ b/') ? line.slice(6) : removedFrom
76 current = path === undefined ? undefined : { removed: [], added: [] }
77 if (path !== undefined && current !== undefined) files.set(path, current)
78 } else if (current !== undefined && line.startsWith('-')) {
79 current.removed.push(line.slice(1))
80 } else if (current !== undefined && line.startsWith('+')) {
81 current.added.push(line.slice(1))
82 }
83 }
84 return new Map([...files].map(([path, text]) => [path, { removed: text.removed.join('\n'), added: text.added.join('\n') }]))
85}
86
87async function gitRoot($: EngineInterface, cwd: string): Promise<string | undefined> {
88 try {
89 const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
90 return ran.exitCode === 0 ? ran.stdout.trim() : undefined
91 } catch {
92 return undefined
93 }
94}
95
96async function projectTestGlobs($: EngineInterface, base: string): Promise<string[]> {
97 try {
98 const config: unknown = JSON.parse(await $.fs.read(`${base}/.claude/workflow.json`))
99 const globs = typeof config === 'object' && config !== null ? (config as { testGlobs?: unknown }).testGlobs : undefined
100 return Array.isArray(globs) ? globs.filter((glob): glob is string => typeof glob === 'string') : []
101 } catch {
102 return []
103 }
104}
105
106async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
107 try {
108 return await $.fs.read(path)
109 } catch {
110 return ''
111 }
112}
113
114export function registerIntegrity(on: On): void {
115 let session: SessionState = { cwd: '', root: undefined, globs: DEFAULT_TEST_GLOBS, reported: new Set() }
116
117 const relative = (path: string): string => {
118 const base = session.root ?? session.cwd
119 return base !== '' && path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path
120 }
121 const isTest = (path: string): boolean => matchesAny(relative(path), [...session.globs, ...SNAPSHOT_GLOBS])
122
123 const findingsFor = (file: string, before: string, after: string): string[] => {
124 const found = weakenings(before, after)
125 return matchesAny(file, SNAPSHOT_GLOBS) ? [...found, 'changed a snapshot: read every line of the change'] : found
126 }
127
128 const withNote = <T extends string>(ran: ToolCallResult<T>, path: string, before: string, after: string) => {
129 if (ran.deny !== undefined || ran.isError === true || !isTest(path)) return ran
130 const file = relative(path)
131 const findings = findingsFor(file, before, after)
132 if (findings.length === 0) return ran
133 for (const finding of findings) session.reported.add(`${file}: ${finding}`)
134 return { ...ran, context: [...(ran.context ?? []), `house: ${file}: ${findings.join(', ')}. ${RULE}`] }
135 }
136
137 on('session.start', async ($, e, next) => {
138 const started = await next(e)
139 const root = await gitRoot($, e.cwd)
140 session = {
141 cwd: e.cwd,
142 root,
143 globs: [...DEFAULT_TEST_GLOBS, ...(await projectTestGlobs($, root ?? e.cwd))],
144 reported: new Set(),
145 }
146 return started
147 })
148
149 on('tool.call', { tool: 'Edit' }, async ($, e, next) => withNote(await next(e), e.file_path, e.old_string, e.new_string))
150
151 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
152 const before = isTest(e.file_path) ? await readOrEmpty($, e.file_path) : ''
153 return withNote(await next(e), e.file_path, before, e.content)
154 })
155
156 on('turn.complete', async ($, e, next) => {
157 const completed = await next(e)
158 if (e.agentId !== undefined || e.reason !== 'answer' || session.root === undefined) return completed
159 const diff = await $.process.run(['git', 'diff', 'HEAD', '-U0', '--no-color', '--no-ext-diff'], { cwd: session.root })
160 if (diff.exitCode !== 0) return completed
161 const fresh: string[] = []
162 for (const [file, change] of changesByFile(diff.stdout)) {
163 if (!isTest(file)) continue
164 for (const finding of findingsFor(file, change.removed, change.added)) {
165 const key = `${file}: ${finding}`
166 if (session.reported.has(key)) continue
167 session.reported.add(key)
168 fresh.push(key)
169 }
170 }
171 if (fresh.length > 0) {
172 const text = ['house: test files changed through the shell this turn:', ...fresh.map(key => `- ${key}`), RULE].join('\n')
173 $.clock.after(0, () => void $.prompt.submit({ text }))
174 }
175 return completed
176 })
177}
178src/safety.ts 155 lines1import type { On } from 'claude-code'
2
3import { segments, tokenize } from './shell'
4
5type Check = (args: readonly string[]) => string | undefined
6
7const WRAPPERS = new Set(['sudo', 'command', 'exec', 'time', 'nohup', 'rtk'])
8const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
9const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
10const KUBE_OPTIONS_WITH_VALUE = new Set(['-n', '--namespace', '--context', '--kube-context', '--kubeconfig', '--cluster', '--user', '-s', '--server'])
11
12const CONTAINED_IN_SANDBOX = new Set(['rm'])
13// The deny rules in settings.json bind a command only inside the sandbox, so outside it a secret path has to ask.
14const SECRET_PATH = /\.env\b|\.ssh\b|\.kube\/config|\.aws\/credentials|\.tfstate|\.pem\b|\.key\b/
15
16const DISCARDS = 'can discard uncommitted work'
17
18function words(segment: string): string[] {
19 const tokens = tokenize(segment.replace(/^[({]\s*/, '').replace(/\s*[)}]$/, ''))
20 let start = 0
21 while (start < tokens.length && (ENV_ASSIGNMENT.test(tokens[start] ?? '') || WRAPPERS.has(tokens[start] ?? ''))) {
22 start += tokens[start] === 'rtk' && tokens[start + 1] === 'proxy' ? 2 : 1
23 }
24 const program = tokens[start]?.split('/').pop()
25 return program === undefined ? [] : [program, ...tokens.slice(start + 1)]
26}
27
28function positional(args: readonly string[], optionsWithValue: ReadonlySet<string>): string[] {
29 const found: string[] = []
30 for (let index = 0; index < args.length; index += 1) {
31 const arg = args[index] ?? ''
32 if (optionsWithValue.has(arg)) index += 1
33 else if (!arg.startsWith('-')) found.push(arg)
34 }
35 return found
36}
37
38function shortFlags(args: readonly string[]): string {
39 return args.filter(arg => /^-[a-zA-Z]+$/.test(arg)).map(arg => arg.slice(1)).join('')
40}
41
42const git: Check = args => {
43 let index = 0
44 while (index < args.length && (args[index] ?? '').startsWith('-')) {
45 index += GIT_OPTIONS_WITH_VALUE.has(args[index] ?? '') ? 2 : 1
46 }
47 const subcommand = args[index]
48 const rest = args.slice(index + 1)
49 const flags = shortFlags(rest)
50 switch (subcommand) {
51 case 'reset':
52 return rest.includes('--hard') ? DISCARDS : undefined
53 case 'clean':
54 return flags.includes('n') || rest.includes('--dry-run') ? undefined : 'deletes untracked files'
55 case 'checkout':
56 case 'switch':
57 return 'switches branches or discards changes'
58 case 'restore': {
59 const onlyStaged = (rest.includes('--staged') || flags.includes('S')) && !rest.includes('--worktree') && !flags.includes('W')
60 return onlyStaged ? undefined : DISCARDS
61 }
62 case 'stash':
63 return rest[0] === 'list' || rest[0] === 'show' ? undefined : 'moves uncommitted work into a stash'
64 case 'branch': {
65 const isDelete = flags.includes('d') || rest.includes('--delete')
66 const isForced = flags.includes('D') || flags.includes('f') || rest.includes('--force')
67 return flags.includes('D') || (isDelete && isForced) ? 'deletes a branch with unmerged work' : undefined
68 }
69 default:
70 return undefined
71 }
72}
73
74const rm: Check = args => {
75 const flags = shortFlags(args)
76 const isRecursive = /[rR]/.test(flags) || args.includes('--recursive')
77 const isForced = flags.includes('f') || args.includes('--force')
78 return isRecursive && isForced ? 'deletes files recursively without asking' : undefined
79}
80
81const terraform: Check = args => {
82 const [subcommand, next] = positional(args, new Set())
83 if (subcommand === 'apply' || subcommand === 'destroy' || subcommand === 'import') return 'changes infrastructure'
84 if (subcommand === 'state' && !['list', 'show', 'pull'].includes(next ?? '')) return 'changes Terraform state'
85 return undefined
86}
87
88const kubectl: Check = args => {
89 const [verb, next] = positional(args, KUBE_OPTIONS_WITH_VALUE)
90 if (['apply', 'delete', 'edit', 'patch', 'scale', 'replace', 'drain'].includes(verb ?? '')) return 'changes a cluster'
91 if (verb === 'rollout' && !['status', 'history'].includes(next ?? '')) return 'changes a cluster'
92 return undefined
93}
94
95const helm: Check = args => {
96 const [subcommand] = positional(args, KUBE_OPTIONS_WITH_VALUE)
97 return ['install', 'upgrade', 'uninstall', 'delete', 'rollback'].includes(subcommand ?? '') ? 'changes a cluster' : undefined
98}
99
100const docker: Check = args => {
101 const [first, second] = positional(args, new Set())
102 if (first === 'system' && second === 'prune') return 'deletes Docker data'
103 if (first === 'volume' && (second === 'rm' || second === 'prune')) return 'deletes Docker volumes'
104 if (first === 'compose' && second === 'down' && (args.includes('-v') || args.includes('--volumes'))) return 'deletes Docker volumes'
105 return undefined
106}
107
108const gh: Check = args => {
109 const [first, second] = positional(args, new Set(['-R', '--repo']))
110 if (first === 'pr' && second === 'merge') return 'merges a pull request'
111 if (first === 'release' && !['list', 'view', 'download'].includes(second ?? '')) return 'changes a release'
112 if (first === 'repo' && second === 'delete') return 'deletes a repository'
113 return undefined
114}
115
116const publish: Check = args => (args.includes('publish') ? 'publishes a package' : undefined)
117
118const CHECKS: Readonly<Record<string, Check>> = {
119 git,
120 rm,
121 terraform,
122 tofu: terraform,
123 kubectl,
124 helm,
125 docker,
126 'docker-compose': args => docker(['compose', ...args]),
127 gh,
128 npm: publish,
129 pnpm: publish,
130 yarn: publish,
131 cargo: publish,
132 dropdb: () => 'drops a database',
133}
134
135export function riskyReason(command: string, unsandboxed = false): string | undefined {
136 for (const segment of segments(command)) {
137 const [program, ...args] = words(segment)
138 const isContained = program !== undefined && !unsandboxed && CONTAINED_IN_SANDBOX.has(program)
139 const what = program === undefined || isContained ? undefined : CHECKS[program]?.(args)
140 if (what !== undefined) return `\`${segment}\` ${what}`
141 if (unsandboxed && SECRET_PATH.test(segment)) return `\`${segment}\` touches a secret outside the sandbox`
142 }
143 return undefined
144}
145
146export function registerSafety(on: On): void {
147 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
148 const verdict = await next(e)
149 if (verdict.decision === 'deny') return verdict
150 const input = (e.input ?? {}) as { command?: unknown; dangerouslyDisableSandbox?: unknown }
151 const reason = riskyReason(typeof input.command === 'string' ? input.command : '', input.dangerouslyDisableSandbox === true)
152 return reason === undefined ? verdict : { decision: 'ask', reason: `house: ${reason}. Approve only if you asked for it.` }
153 }).catch(() => ({ decision: 'ask', reason: 'house: the safety check failed. Approve only if you asked for this command.' }))
154}
155src/typography.ts 89 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { tokenize } from './shell'
4
5const EM_DASH = String.fromCharCode(0x2014)
6const EM_DASH_WITH_SPACES = new RegExp(`[ \t]*${EM_DASH}[ \t]*`, 'g')
7const WRITES_PROSE = /\b(?:git\s+(?:-\S+\s+)*commit|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment))\b/
8const MESSAGE_FILE_FLAGS = new Set(['-F', '--file', '--body-file'])
9const ADVICE = 'Use a hyphen, comma, colon or parentheses instead (CLAUDE.md, Writing).'
10
11export function countEmDashes(text: string): number {
12 return text.split(EM_DASH).length - 1
13}
14
15export function addedEmDashes(before: string, after: string): number {
16 return Math.max(0, countEmDashes(after) - countEmDashes(before))
17}
18
19export function rewriteEmDashes(text: string): string {
20 return text.replace(EM_DASH_WITH_SPACES, (match, offset: number) => {
21 const end = offset + match.length
22 return end === text.length || text[end] === '\n' ? ' -' : ' - '
23 })
24}
25
26export function writesProse(command: string): boolean {
27 return WRITES_PROSE.test(command)
28}
29
30export function messageFiles(command: string): string[] {
31 const tokens = tokenize(command)
32 const files: string[] = []
33 tokens.forEach((token, index) => {
34 const [flag, inline] = token.split('=', 2)
35 if (flag === undefined || !MESSAGE_FILE_FLAGS.has(flag)) return
36 const file = inline ?? tokens[index + 1]
37 if (file !== undefined && file !== '-') files.push(file)
38 })
39 return files
40}
41
42async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
43 try {
44 return await $.fs.read(path)
45 } catch {
46 return ''
47 }
48}
49
50function refusal(added: number): { deny: string } {
51 return { deny: `house: this adds ${added} em dash${added === 1 ? '' : 'es'}. ${ADVICE}` }
52}
53
54function checkFailed(what: string): { deny: string } {
55 return { deny: `house: the em dash check on ${what} failed, so the call is refused.` }
56}
57
58export function registerTypography(on: On): void {
59 on('tool.call', { tool: 'Edit' }, ($, e, next) => {
60 const added = addedEmDashes(e.old_string, e.new_string)
61 return added > 0 ? refusal(added) : next(e)
62 }).catch(($, e, next) => (next.called ? next(e) : checkFailed('an Edit')))
63
64 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
65 const added = addedEmDashes(await readOrEmpty($, e.file_path), e.content)
66 return added > 0 ? refusal(added) : next(e)
67 }).catch(($, e, next) => (next.called ? next(e) : checkFailed('a Write')))
68
69 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => {
70 const added = e.edit_mode === 'delete' ? 0 : countEmDashes(e.new_source)
71 return added > 0 ? refusal(added) : next(e)
72 }).catch(($, e, next) => (next.called ? next(e) : checkFailed('a notebook edit')))
73
74 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75 if (!writesProse(e.command)) return next(e)
76 let added = countEmDashes(e.command)
77 for (const file of messageFiles(e.command)) added += countEmDashes(await readOrEmpty($, file))
78 return added > 0 ? refusal(added) : next(e)
79 }).catch(($, e, next) => (next.called ? next(e) : checkFailed('a commit or GitHub message')))
80
81 on('session.append', { door: 'response' }, ($, e, next) => {
82 if (e.origin.kind !== 'model') return next(e)
83 const content = e.message.content.map(block =>
84 block.type === 'text' && typeof block.text === 'string' ? { ...block, text: rewriteEmDashes(block.text) } : block,
85 )
86 return next({ ...e, message: { ...e.message, content } })
87 })
88}
89src/glob.ts 42 lines1// The workflow mod has its own copy: the two mods ship separately and share no code.
2
3const compiled = new Map<string, RegExp>()
4
5function escape(text: string): string {
6 return text.replace(/[.+^${}()|[\]\\]/g, '\\$&')
7}
8
9function globToRegExp(glob: string): RegExp {
10 let source = ''
11 for (let index = 0; index < glob.length; index += 1) {
12 const char = glob[index] ?? ''
13 if (char === '*' && glob[index + 1] === '*') {
14 const isDirectoryPrefix = glob[index + 2] === '/'
15 source += isDirectoryPrefix ? '(?:.*/)?' : '.*'
16 index += isDirectoryPrefix ? 2 : 1
17 } else if (char === '*') {
18 source += '[^/]*'
19 } else if (char === '?') {
20 source += '[^/]'
21 } else if (char === '{' && glob.indexOf('}', index) > index) {
22 const end = glob.indexOf('}', index)
23 source += `(?:${glob.slice(index + 1, end).split(',').map(escape).join('|')})`
24 index = end
25 } else {
26 source += escape(char)
27 }
28 }
29 return new RegExp(`^${source}$`)
30}
31
32export function matchesAny(path: string, globs: readonly string[]): boolean {
33 return globs.some(glob => {
34 let pattern = compiled.get(glob)
35 if (pattern === undefined) {
36 pattern = globToRegExp(glob)
37 compiled.set(glob, pattern)
38 }
39 return pattern.test(path)
40 })
41}
42src/shell.ts 64 lines1const SEPARATORS = ['&&', '||', ';', '|', '&', '\n']
2
3function isQuote(char: string | undefined): char is '"' | "'" {
4 return char === '"' || char === "'"
5}
6
7export function segments(command: string): string[] {
8 const parts: string[] = []
9 let current = ''
10 let quote: string | undefined
11 for (let index = 0; index < command.length; index += 1) {
12 const char = command[index] ?? ''
13 if (quote !== undefined) {
14 current += char
15 if (char === quote) quote = undefined
16 continue
17 }
18 if (isQuote(char)) {
19 quote = char
20 current += char
21 continue
22 }
23 const separator = SEPARATORS.find(candidate => command.startsWith(candidate, index))
24 if (separator !== undefined) {
25 parts.push(current)
26 current = ''
27 index += separator.length - 1
28 continue
29 }
30 current += char
31 }
32 parts.push(current)
33 return parts.map(part => part.trim()).filter(part => part.length > 0)
34}
35
36export function tokenize(segment: string): string[] {
37 const tokens: string[] = []
38 let current = ''
39 let hasToken = false
40 let quote: string | undefined
41 for (const char of segment) {
42 if (quote !== undefined) {
43 if (char === quote) quote = undefined
44 else current += char
45 continue
46 }
47 if (isQuote(char)) {
48 quote = char
49 hasToken = true
50 continue
51 }
52 if (/\s/.test(char)) {
53 if (hasToken) tokens.push(current)
54 current = ''
55 hasToken = false
56 continue
57 }
58 current += char
59 hasToken = true
60 }
61 if (hasToken) tokens.push(current)
62 return tokens
63}
64