SLOPSHOPPER

house-rules

Turn CLAUDE.md rules into hard blocks: protected paths, no new report files, banned commands, and rules re-pinned every N prompts and after compaction.

newguardcommandtoastprompttimer
v0.1.0MITupdated 2026-10-06Jvrd97/claude-house-rules
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · house-rules
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /house-rules ⎿ house-rules: No rules file, so the built-in default is on. ⎿ house-rules: ⎿ house-rules: noNewFiles (no new files) ⎿ house-rules: *.md named SUMMARY, REPORT, NOTES, IMPLEMENTATION, ANALYSIS, CHANGES or PLAN except docs/**, .claude/**, REA ⎿ house-rules: ⎿ house-rules: Run /house-rules init to write a starter .claude/house-rules.json. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

house-rules

A Claude Code mod that turns the rules you keep repeating in CLAUDE.md into blocks Claude cannot ignore: protected paths, no new report files, banned commands, and rules re-pinned into context as the session goes on.

● Bash(echo "hello" >> secret.txt)
  ⎿  Error: Blocked by house rules: secret.txt is protected (holds a test token). Do not edit,
     move or delete it. If the task really needs that, stop and ask the user: they can change
     it themselves or edit the rule in .claude/house-rules.json.

● Write(IMPLEMENTATION_SUMMARY.md)
  ⎿  Error: Blocked by house rules: do not create IMPLEMENTATION_SUMMARY.md (report-style
     markdown (SUMMARY, REPORT, NOTES, IMPLEMENTATION, ANALYSIS, CHANGES, PLAN) is not wanted
     outside docs/). Put what you meant to write in your reply to the user, ...

● Bash(cd web && npm install react)
  ⎿  Error: Blocked by house rules: `npm install react` matches /^(npm|yarn) (i|install|add)\b/
     (this repo uses pnpm). Run the command this rule asks for instead; ...

Why

CLAUDE.md is context, not enforcement. The model reads it once, weighs it against everything else, and drifts:

Writing those hooks by hand means a shell script per rule, JSON on stdin and exit codes. This mod is the hook, driven by one small JSON file.

What it does

RuleWhat is blocked
protectEdit, MultiEdit, Write and NotebookEdit on matching paths, and Bash that writes, moves or deletes them: redirections (>, >>), tee, rm, mv, cp/ln/install onto them, sed -i, perl -i, truncate, touch, dd of=, git rm/mv/restore/checkout --. For a glob with a slash, deleting or moving a folder that holds a protected path counts too
noNewFilesWrite of a matching file that does not exist yet (editing existing files stays allowed), and Bash that creates one (cat > REPORT.md <<EOF, touch, cp, mv)
commandsA Bash command whose segment matches the regex; each part of a && / `\\ / ; / \ chain, $(...) and bash -c "..." is tested on its own, with and without sudo/env/VAR=` in front
pinThe listed rules are added to the context the model reads, on the first prompt, every pinEvery prompts after it and right after each compaction
defaultWith no rules file: no new report-style markdown outside docs/

The deny reason is the rule's why plus what to do instead, so the model gets a reason it can act on, not a bare refusal.

/house-rules prints the rules in force, the file each came from, how many times each blocked something this session, the pins and any errors in the file. /house-rules init writes a commented starter .claude/house-rules.json if there is none.

The rules file

.claude/house-rules.json at the project root, merged with ~/.claude/house-rules.json if you have one. Where both name the same glob or regex, the project's entry wins, and so does its pinEvery.

{
  "protect":    [{ "glob": ".env*", "why": "secrets" }, { "glob": "migrations/applied/**", "why": "applied migrations are immutable" }],
  "noNewFiles": [{ "glob": "**/*.md", "except": ["docs/**", "README.md", "CHANGELOG.md"], "why": "no report files" }],
  "commands":   [{ "match": "^(npm|yarn) (i|install|add)\\b", "why": "this repo uses pnpm" }],
  "pin":        ["Use uv, not pip.", "Never edit applied migrations."],
  "pinEvery":   10
}
  • A plain string works too in protect, noNewFiles and commands ("protect": [".env*"]); the reason is then a generic one.
  • Globs, gitignore style: a glob with no slash matches a file or folder name at any depth (.env*, secrets); a glob with a slash is relative to the project root (migrations/applied/**); one starting with / is absolute. A glob that matches a folder covers everything inside it. *, ?, [a-z], [!a-z], ** and {a,b} work.
  • match is a JavaScript regular expression.
  • pinEvery: 0 pins only after compaction.
  • JSON has no comments: any field starting with $, like "$comment", is ignored, at the top and inside entries.

The file is checked when it loads. A bad regex, a broken glob, an unknown field or a wrong type is shown once as a toast and listed under /house-rules; that entry is skipped and the rest still apply. A file that is not valid JSON at all counts as no file, so the default rule stays on until you fix it.

Install

You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

From the marketplace in this repo:

/plugin marketplace add Jvrd97/claude-house-rules
/plugin install house-rules@house-rules

Or straight from disk, for one session:

git clone https://github.com/Jvrd97/claude-house-rules.git
claude --plugin-dir ./claude-house-rules

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
defaultRulestrueWith no rules file, block creating new *SUMMARY*, *REPORT*, *NOTES*, *IMPLEMENTATION*, *ANALYSIS*, *CHANGES* and *PLAN* markdown files outside docs/ and .claude/ (README, CHANGELOG and CONTRIBUTING stay allowed)
globalFiletrueAlso read ~/.claude/house-rules.json

The default rule matches whole words of the name, case-insensitive: FINAL_SUMMARY.md, implementation-plan.md and TestReport.md are blocked, explanation.md and reporting-api.md are not.

How it works

  • tool.call sees every tool call before it runs, the permission prompt included. For Edit, MultiEdit, Write, NotebookEdit and Bash the mod checks the rules and answers { deny: reason }; the model receives the reason as the tool's error. Every other tool passes without a file system call.
  • The rules file is read at session.start and read again on a tool call or a prompt when its mtime changed, so an edit applies on the next call without a restart.
  • A path is checked as written and, through $.fs.stat, where it really lands, so a symlink to a protected file is protected too.
  • prompt.submit attaches the pins as context the model reads beside the prompt and the user does not see. session.compact marks that a compaction happened; half a second later the mod appends the pins as a row the model reads ($.session.append). If a prompt comes first, or the append is refused, the pins go with that prompt instead.
  • /house-rules init is the only time the mod writes a file: .claude/house-rules.json, and only when it does not exist. Block counts and the prompt counter live in the session's state and are gone when it ends. No network.

What it cannot do:

  • Parse every shell. The Bash check reads the common ways to write a file; a path built at run time (rm "$FILE", python -c "open('x','w')", find . -delete, xargs rm) is not seen. The relative paths in a Bash command are resolved against the session's working directory, plus any cd in the same command; a cd from an earlier Bash call is not tracked.
  • Match a rule against a spelling it cannot see: a hard link or a case alias on a case-insensitive disk keeps its own name.
  • Stop you. Rules apply to Claude's tool calls, not to commands you run yourself, and anyone who can edit .claude/house-rules.json can change them. Keep the file in git and review changes to it like code.
  • Make the model obey pins. Pinned text is still context; it is the blocks that are enforced.

Possible false positives: a name glob like secrets covers any folder of that name, and **/*.md with no exceptions blocks every new markdown file, CLAUDE.md included. The starter file lists the usual exceptions.

Develop

Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:

tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

По-русски

CLAUDE.md — это совет, а не запрет: модель читает его и всё равно правит запрещённые файлы, ставит пакеты не тем менеджером и плодит SUMMARY.md. Мод превращает такие правила в блокировки на уровне вызова инструментов.

  • protect — пути, которые нельзя править, двигать и удалять, в том числе через Bash.
  • noNewFiles — какие новые файлы создавать нельзя; без файла правил по умолчанию запрещены отчёты вида SUMMARY/REPORT/PLAN.md вне docs/.
  • commands — запрещённые команды; проверяется каждая часть цепочки &&, ;, |.
  • pin — правила, которые мод напоминает модели каждые N промптов и сразу после сжатия контекста.

Правила лежат в .claude/house-rules.json; /house-rules показывает, что действует и сколько раз сработало, /house-rules init создаёт стартовый файл.

License

MIT

Source 3 files
hooks/register.ts 342 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { LoadedRules, RuleSet } from '../types'
5import {
6  STARTER_FILE,
7  analyzeBash,
8  commandVerdict,
9  defaultRules,
10  emptyRules,
11  filePathOf,
12  globParts,
13  mergeRules,
14  newFileVerdict,
15  parseRules,
16  pinEveryOf,
17  pinText,
18  protectVerdict,
19  report,
20  resolvePath,
21  shellNameMatches,
22  shouldPin,
23} from './logic'
24import type { PathFacts, ShellTarget, Verdict } from './logic'
25
26const COMMAND = 'house-rules'
27const RULES_FILE = '.claude/house-rules.json'
28const GLOBAL_NAME = '~/.claude/house-rules.json'
29const ERROR_TOAST_MS = 10_000
30/** Lets a compaction install its summary before the pins are appended after it. */
31const COMPACT_SETTLE_MS = 500
32/** Paths one Bash call is checked for at most; each costs a stat or two. */
33const MAX_TARGETS = 64
34const GUARDED_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
35
36const loaded = atom({ plugin: 'house-rules', key: 'loaded' } as const, null)
37const blocks = atom({ plugin: 'house-rules', key: 'blocks' } as const, {})
38const prompts = atom({ plugin: 'house-rules', key: 'prompts' } as const, 0)
39const toasted = atom({ plugin: 'house-rules', key: 'toasted' } as const, [])
40const pinPending = atom({ plugin: 'house-rules', key: 'pinPending' } as const, false)
41
42type Settings = { usesDefaults: boolean; readsGlobal: boolean }
43
44/** Where the rules come from and what paths are relative to. */
45type Places = { root: string; roots: string[]; cwd: string; home: string | undefined; project: string; global: string | null }
46
47async function placesOf($: EngineInterface, settings: Settings): Promise<Places> {
48  const root = await $.session.root()
49  const cwd = await $.session.cwd()
50  const home = await $.env.get('HOME')
51  const realRoot = await $.fs
52    .stat(root, { resolve: true })
53    .then(stat => stat.realPath)
54    .catch(() => undefined)
55  const project = `${root}/${RULES_FILE}`
56  const global = settings.readsGlobal && home !== undefined ? `${home}/${RULES_FILE}` : null
57
58  return {
59    root,
60    roots: realRoot === undefined || realRoot === root ? [root] : [root, realRoot],
61    cwd,
62    home,
63    project,
64    global: global === project ? null : global,
65  }
66}
67
68/** A missing file is the normal case, not an error: it reads as null. */
69async function mtimeOf($: EngineInterface, path: string): Promise<number | null> {
70  return $.fs
71    .stat(path)
72    .then(stat => stat.mtimeMs)
73    .catch(() => null)
74}
75
76async function toastNewErrors($: EngineInterface, errors: readonly string[]): Promise<void> {
77  const seen = await read($, toasted)
78  const fresh = errors.filter(error => !seen.includes(error))
79
80  if (fresh.length === 0) {
81    return
82  }
83
84  await update($, toasted, list => [...(list ?? []), ...fresh])
85  const more = fresh.length === 1 ? '' : ` (+${fresh.length - 1} more)`
86  $.ui.toast(`house-rules: ${fresh[0]}${more}. That entry is ignored; /house-rules lists all.`, { timeoutMs: ERROR_TOAST_MS })
87}
88
89/** The rules in force: read again only when a rules file's mtime, the root or the settings changed. */
90async function rulesOf($: EngineInterface, settings: Settings, places: Places): Promise<LoadedRules> {
91  const projectMtime = await mtimeOf($, places.project)
92  const globalMtime = places.global === null ? null : await mtimeOf($, places.global)
93  const key = [places.root, projectMtime, globalMtime, settings.usesDefaults, settings.readsGlobal].join('|')
94  const cached = await read($, loaded)
95
96  if (cached !== null && cached.key === key) {
97    return cached
98  }
99
100  const sources = [
101    { path: places.global, mtime: globalMtime, name: GLOBAL_NAME },
102    { path: places.project, mtime: projectMtime, name: RULES_FILE },
103  ]
104  const sets: RuleSet[] = []
105  const files: string[] = []
106  const errors: string[] = []
107
108  for (const source of sources) {
109    if (source.path === null || source.mtime === null) {
110      continue
111    }
112
113    const text = await $.fs.read(source.path).catch((error: unknown) => {
114      errors.push(`${source.name}: cannot be read (${error instanceof Error ? error.message : String(error)})`)
115
116      return null
117    })
118
119    if (text === null) {
120      continue
121    }
122
123    const parsed = parseRules(text, source.name)
124    errors.push(...parsed.errors)
125
126    if (parsed.isReadable) {
127      sets.push(parsed.rules)
128      files.push(source.name)
129    }
130  }
131
132  const isDefault = files.length === 0 && settings.usesDefaults
133  const next: LoadedRules = {
134    key,
135    rules: isDefault ? defaultRules() : files.length === 0 ? emptyRules() : mergeRules(sets),
136    files,
137    errors,
138    isDefault,
139  }
140
141  await update($, loaded, () => next)
142  await toastNewErrors($, errors)
143
144  return next
145}
146
147async function factsOf($: EngineInterface, path: string, isRemoval: boolean): Promise<PathFacts> {
148  const stat = await $.fs.stat(path, { resolve: true }).catch(() => null)
149  const realPath = stat?.realPath
150
151  return realPath === undefined || realPath === path
152    ? { path, exists: stat !== null, isRemoval }
153    : { path, realPath, exists: true, isRemoval }
154}
155
156/** A target like `/work/*.txt` becomes the files the shell would expand it to, plus itself. */
157async function expandTargets($: EngineInterface, targets: readonly ShellTarget[]): Promise<ShellTarget[]> {
158  const expanded: ShellTarget[] = []
159
160  for (const target of targets) {
161    expanded.push(target)
162    const parts = target.hasGlob && target.intoDir === undefined ? globParts(target.path) : null
163
164    if (parts === null) {
165      continue
166    }
167
168    const entries = await $.fs.list(parts.dir).catch(() => [])
169
170    for (const entry of entries) {
171      if (shellNameMatches(parts.pattern, entry.name)) {
172        expanded.push({ path: `${parts.dir === '/' ? '' : parts.dir}/${entry.name}`, mayCreate: target.mayCreate, hasGlob: false })
173      }
174    }
175  }
176
177  return expanded.slice(0, MAX_TARGETS)
178}
179
180async function judgeBash($: EngineInterface, rules: RuleSet, places: Places, command: string): Promise<Verdict | null> {
181  const analysis = analyzeBash(command, places.cwd, places.home)
182  const byCommand = commandVerdict(rules, analysis.segments)
183
184  if (byCommand !== null || (rules.protect.length === 0 && rules.noNewFiles.length === 0)) {
185    return byCommand
186  }
187
188  for (const target of await expandTargets($, analysis.targets)) {
189    if (target.intoDir !== undefined) {
190      const kind = await $.fs
191        .stat(target.intoDir)
192        .then(stat => stat.kind)
193        .catch(() => null)
194
195      if (kind !== 'dir') {
196        continue
197      }
198    }
199
200    const facts = await factsOf($, target.path, !target.mayCreate)
201    const verdict = protectVerdict(rules, facts, places.roots) ?? (target.mayCreate ? newFileVerdict(rules, facts, places.roots) : null)
202
203    if (verdict !== null) {
204      return verdict
205    }
206  }
207
208  return null
209}
210
211/** The rule a tool call breaks, or null. Tools that write nothing are let through without a file system call. */
212async function judge($: EngineInterface, e: ToolCallInput, settings: Settings): Promise<Verdict | null> {
213  if (!GUARDED_TOOLS.has(e.tool)) {
214    return null
215  }
216
217  const places = await placesOf($, settings)
218  const { rules } = await rulesOf($, settings, places)
219  const input = e as unknown as Readonly<Record<string, unknown>>
220
221  if (e.tool === 'Bash') {
222    return judgeBash($, rules, places, typeof input.command === 'string' ? input.command : '')
223  }
224
225  const written = filePathOf(e.tool, input)
226
227  if (written === null || (rules.protect.length === 0 && rules.noNewFiles.length === 0)) {
228    return null
229  }
230
231  const facts = await factsOf($, resolvePath(places.cwd, written, places.home), false)
232
233  return protectVerdict(rules, facts, places.roots) ?? (e.tool === 'Write' ? newFileVerdict(rules, facts, places.roots) : null)
234}
235
236/** The pinned rules as the model reads them, or null when there are none. */
237async function pinsOf($: EngineInterface, settings: Settings): Promise<{ text: string; every: number } | null> {
238  const current = await rulesOf($, settings, await placesOf($, settings))
239
240  return current.rules.pin.length === 0 ? null : { text: pinText(current.rules.pin, current.files), every: pinEveryOf(current.rules) }
241}
242
243async function appendPinsAfterCompaction($: EngineInterface, settings: Settings): Promise<void> {
244  if (!(await read($, pinPending))) {
245    return
246  }
247
248  const pins = await pinsOf($, settings)
249
250  if (pins === null) {
251    await update($, pinPending, () => false)
252
253    return
254  }
255
256  const stored = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: pins.text }] } }).catch((error: unknown) => {
257    $.ui.log(`house-rules: could not append the pinned rules after compaction (${error instanceof Error ? error.message : String(error)}); they go with the next prompt`)
258
259    return null
260  })
261
262  if (stored !== null && !('deny' in stored && stored.deny !== undefined)) {
263    await update($, pinPending, () => false)
264  }
265}
266
267async function initFile($: EngineInterface, settings: Settings): Promise<string> {
268  const places = await placesOf($, settings)
269
270  if (await $.fs.exists(places.project)) {
271    return `${RULES_FILE} already exists; house-rules leaves it as it is. Run /house-rules to see what it holds.`
272  }
273
274  await $.fs.write(places.project, STARTER_FILE)
275  await update($, loaded, () => null)
276  await rulesOf($, settings, places)
277
278  return `Wrote ${RULES_FILE} with example rules. Edit it: changes apply on the next tool call.\n\n${STARTER_FILE}`
279}
280
281async function showRules($: EngineInterface, settings: Settings): Promise<string> {
282  const current = await rulesOf($, settings, await placesOf($, settings))
283
284  return report(current, await read($, blocks), await read($, prompts))
285}
286
287export const register: Register = (on, options) => {
288  const settings: Settings = { usesDefaults: options.defaultRules !== false, readsGlobal: options.globalFile !== false }
289
290  on('session.start', async ($, e, next) => {
291    await $.command.register({ name: COMMAND, description: 'Show the house rules in force and what they blocked; init writes a starter file', argumentHint: '[init]' })
292    await rulesOf($, settings, await placesOf($, settings))
293
294    return next(e)
295  })
296
297  on('tool.call', async ($, e, next) => {
298    const verdict = await judge($, e, settings)
299
300    if (verdict === null) {
301      return next(e)
302    }
303
304    await update($, blocks, map => ({ ...map, [verdict.ruleId]: (map?.[verdict.ruleId] ?? 0) + 1 }))
305
306    return { deny: verdict.reason }
307  })
308
309  on('prompt.submit', async ($, e, next) => {
310    const count = await update($, prompts, n => (n ?? 0) + 1)
311    const pins = await pinsOf($, settings)
312    const isAfterCompaction = await read($, pinPending)
313
314    if (pins === null || !(isAfterCompaction || shouldPin(count, pins.every))) {
315      return next(e)
316    }
317
318    await update($, pinPending, () => false)
319
320    return next({ ...e, context: [...(e.context ?? []), pins.text] })
321  })
322
323  on('session.compact', async ($, e, next) => {
324    const result = await next(e)
325
326    if (result.skip === undefined && e.trigger !== 'precompute' && e.agentId === undefined) {
327      await update($, pinPending, () => true)
328      $.clock.after(COMPACT_SETTLE_MS, () => {
329        void appendPinsAfterCompaction($, settings)
330      })
331    }
332
333    return result
334  })
335
336  on('command.run', { command: COMMAND }, async ($, e) => {
337    const isInit = e.args.trim().toLowerCase() === 'init'
338
339    return { text: isInit ? await initFile($, settings) : await showRules($, settings) }
340  })
341}
342
hooks/logic.ts 1446 lines
1import type { CommandRule, LoadedRules, NoNewFilesRule, ProtectRule, RuleSet } from '../types'
2
3export const DEFAULT_PIN_EVERY = 10
4/** A brace glob expands to at most this many plain globs; more is a typo or an attack on the matcher. */
5const MAX_BRACE_EXPANSIONS = 256
6const PROJECT_FILE = '.claude/house-rules.json'
7
8export const GENERIC_WHY = {
9  protect: 'this path is protected by the house rules',
10  noNewFiles: 'new files like this are not wanted in this repo',
11  commands: 'this command is not allowed in this repo',
12} as const
13
14const KNOWN_FIELDS = ['protect', 'noNewFiles', 'commands', 'pin', 'pinEvery'] as const
15
16export const emptyRules = (): RuleSet => ({ protect: [], noNewFiles: [], commands: [], pin: [], pinEvery: null })
17
18// ---------------------------------------------------------------- paths
19
20/** Folds `.`, `..` and repeated slashes; keeps a leading slash. */
21export const normalizePath = (path: string): string => {
22  const isAbsolute = path.startsWith('/')
23  const parts: string[] = []
24
25  for (const part of path.split('/')) {
26    if (part === '' || part === '.') {
27      continue
28    }
29
30    if (part === '..') {
31      if (parts.length > 0 && parts[parts.length - 1] !== '..') {
32        parts.pop()
33      } else if (!isAbsolute) {
34        parts.push('..')
35      }
36
37      continue
38    }
39
40    parts.push(part)
41  }
42
43  const joined = parts.join('/')
44
45  return isAbsolute ? `/${joined}` : joined === '' ? '.' : joined
46}
47
48/** An absolute path for `path` as a shell in `base` would read it; `~/` needs `home`. */
49export const resolvePath = (base: string, path: string, home?: string): string => {
50  if (path.startsWith('/')) {
51    return normalizePath(path)
52  }
53
54  if ((path === '~' || path.startsWith('~/')) && home !== undefined) {
55    return normalizePath(`${home}/${path.slice(1)}`)
56  }
57
58  return normalizePath(`${base}/${path}`)
59}
60
61/** The path relative to the first root it lies under, '' for a root itself, null when outside all. */
62export const relativeTo = (roots: readonly string[], absolute: string): string | null => {
63  const path = normalizePath(absolute)
64
65  for (const root of roots) {
66    const clean = normalizePath(root)
67
68    if (path === clean) {
69      return ''
70    }
71
72    const prefix = clean === '/' ? '/' : `${clean}/`
73
74    if (path.startsWith(prefix)) {
75      return path.slice(prefix.length)
76    }
77  }
78
79  return null
80}
81
82export const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
83
84const dirName = (path: string): string => {
85  const cut = path.lastIndexOf('/')
86
87  return cut <= 0 ? (cut === 0 ? '/' : '.') : path.slice(0, cut)
88}
89
90/** 'a/b/c' → ['a', 'a/b', 'a/b/c']; '/x/y' → ['/x', '/x/y']: the path and every folder above it. */
91const prefixesOf = (path: string): string[] => {
92  const isAbsolute = path.startsWith('/')
93  const parts = path.split('/').filter(part => part !== '')
94
95  return parts.map((_, index) => `${isAbsolute ? '/' : ''}${parts.slice(0, index + 1).join('/')}`)
96}
97
98// ---------------------------------------------------------------- globs
99
100const splitTopLevel = (body: string): string[] => {
101  const parts: string[] = []
102  let depth = 0
103  let start = 0
104
105  for (let index = 0; index < body.length; index++) {
106    const char = body[index]
107
108    if (char === '\\') {
109      index++
110    } else if (char === '{') {
111      depth++
112    } else if (char === '}') {
113      depth--
114    } else if (char === ',' && depth === 0) {
115      parts.push(body.slice(start, index))
116      start = index + 1
117    }
118  }
119
120  parts.push(body.slice(start))
121
122  return parts
123}
124
125/** `{a,b}c` → ['ac', 'bc'], nested groups too; a group with no comma stays literal. */
126export const expandBraces = (glob: string): string[] => {
127  let depth = 0
128  let start = -1
129
130  for (let index = 0; index < glob.length; index++) {
131    const char = glob[index]
132
133    if (char === '\\') {
134      index++
135    } else if (char === '{') {
136      if (depth === 0) {
137        start = index
138      }
139
140      depth++
141    } else if (char === '}' && depth > 0) {
142      depth--
143
144      if (depth === 0) {
145        const parts = splitTopLevel(glob.slice(start + 1, index))
146
147        if (parts.length > 1) {
148          const head = glob.slice(0, start)
149          const tail = glob.slice(index + 1)
150
151          return parts.flatMap(part => expandBraces(`${head}${part}${tail}`)).slice(0, MAX_BRACE_EXPANSIONS + 1)
152        }
153      }
154    }
155  }
156
157  return [glob]
158}
159
160/** Why a glob cannot be used, or null when it can. */
161export const globProblem = (glob: string): string | null => {
162  if (glob.trim() === '') {
163    return 'is empty'
164  }
165
166  let braces = 0
167  let isInClass = false
168
169  for (let index = 0; index < glob.length; index++) {
170    const char = glob[index]
171
172    if (char === '\\') {
173      index++
174    } else if (isInClass) {
175      isInClass = char !== ']'
176    } else if (char === '[') {
177      isInClass = true
178      // A ']' right after '[' (or '[!') is a member, not the end.
179      if (glob[index + 1] === '!' || glob[index + 1] === '^') {
180        index++
181      }
182
183      if (glob[index + 1] === ']') {
184        index++
185      }
186    } else if (char === '{') {
187      braces++
188    } else if (char === '}') {
189      braces--
190
191      if (braces < 0) {
192        return 'has a "}" with no "{" before it'
193      }
194    }
195  }
196
197  if (isInClass) {
198    return 'has a "[" that is never closed'
199  }
200
201  if (braces > 0) {
202    return 'has a "{" that is never closed'
203  }
204
205  const expanded = expandBraces(glob)
206
207  if (expanded.length > MAX_BRACE_EXPANSIONS) {
208    return `expands to more than ${MAX_BRACE_EXPANSIONS} patterns`
209  }
210
211  for (const one of expanded) {
212    if (one.split('/').some(segment => segment.includes('**') && segment !== '**')) {
213      return 'uses "**" inside a name; write "**/" for any folders or "*" for part of a name'
214    }
215  }
216
217  return null
218}
219
220const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
221
222/** One path segment of a glob as a regular expression source. */
223const segmentSource = (segment: string): string => {
224  let out = ''
225
226  for (let index = 0; index < segment.length; index++) {
227    const char = segment[index] ?? ''
228
229    if (char === '\\') {
230      out += escapeRegExp(segment[index + 1] ?? '')
231      index++
232    } else if (char === '*') {
233      out += '[^/]*'
234    } else if (char === '?') {
235      out += '[^/]'
236    } else if (char === '[') {
237      const close = segment.indexOf(']', index + 2)
238
239      if (close === -1) {
240        out += '\\['
241        continue
242      }
243
244      let body = segment.slice(index + 1, close)
245      const isNegated = body.startsWith('!') || body.startsWith('^')
246
247      if (isNegated) {
248        body = body.slice(1)
249      }
250
251      out += `[${isNegated ? '^' : ''}${body.replace(/\\/g, '\\\\').replace(/]/g, '\\]')}]`
252      index = close
253    } else {
254      out += escapeRegExp(char)
255    }
256  }
257
258  return out
259}
260
261const plainGlobSource = (glob: string): string => {
262  const segments = glob.split('/')
263
264  return segments
265    .map((segment, index) => {
266      const isLast = index === segments.length - 1
267
268      if (segment === '**') {
269        return isLast ? '.*' : '(?:[^/]+/)*'
270      }
271
272      return segmentSource(segment) + (isLast ? '' : '/')
273    })
274    .join('')
275}
276
277const cleanGlob = (glob: string): string => glob.trim().replace(/^(\.\/)+/, '').replace(/(.)\/+$/, '$1')
278
279const compiled = new Map<string, RegExp>()
280
281/** The whole glob, braces expanded, as one anchored regular expression. */
282export const globRegExp = (glob: string, ignoreCase = false): RegExp => {
283  const key = `${ignoreCase ? 'i' : ''}:${glob}`
284  const known = compiled.get(key)
285
286  if (known !== undefined) {
287    return known
288  }
289
290  const sources = expandBraces(cleanGlob(glob)).slice(0, MAX_BRACE_EXPANSIONS).map(plainGlobSource)
291  const made = new RegExp(`^(?:${sources.join('|')})$`, ignoreCase ? 'i' : '')
292  compiled.set(key, made)
293
294  return made
295}
296
297/**
298 * Whether a glob covers an absolute path, gitignore style: a glob with no
299 * slash matches a file or folder name at any depth, one with a slash is
300 * relative to the project root, one starting with '/' is an absolute path,
301 * and a glob that matches a folder covers everything inside it.
302 */
303export const globCovers = (glob: string, absolute: string, roots: readonly string[], ignoreCase = false): boolean => {
304  const clean = cleanGlob(glob)
305  const pattern = globRegExp(clean, ignoreCase)
306  const path = normalizePath(absolute)
307
308  if (clean.startsWith('/')) {
309    return prefixesOf(path).some(prefix => pattern.test(prefix))
310  }
311
312  const relative = relativeTo(roots, path)
313
314  if (clean.includes('/')) {
315    return relative !== null && relative !== '' && prefixesOf(relative).some(prefix => pattern.test(prefix))
316  }
317
318  if (relative === null) {
319    return pattern.test(baseName(path))
320  }
321
322  return relative.split('/').some(segment => segment !== '' && pattern.test(segment))
323}
324
325/** A shell glob in one file name (`*.txt`), as a shell expands it: `*` skips names starting with a dot. */
326export const shellNameMatches = (pattern: string, name: string): boolean => {
327  if (name.startsWith('.') && !pattern.startsWith('.')) {
328    return false
329  }
330
331  return new RegExp(`^${segmentSource(pattern)}$`).test(name)
332}
333
334// ---------------------------------------------------------------- rules file
335
336const REPORT_WORDS = new Set(['SUMMARY', 'SUMMARIES', 'REPORT', 'REPORTS', 'NOTES', 'IMPLEMENTATION', 'ANALYSIS', 'ANALYSES', 'CHANGES', 'PLAN', 'PLANS'])
337
338/**
339 * Whether a file name reads as a report Claude writes for itself:
340 * FINAL_SUMMARY.md, implementation-plan.md, TestReport.md. Whole words only,
341 * so explanation.md and reporting-api.md stay allowed.
342 */
343export const isReportName = (name: string): boolean => {
344  if (!/\.md$/i.test(name)) {
345    return false
346  }
347
348  const words = name
349    .slice(0, -'.md'.length)
350    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
351    .split(/[^A-Za-z0-9]+/)
352
353  return words.some(word => REPORT_WORDS.has(word.toUpperCase()))
354}
355
356export const DEFAULT_SOURCE = 'built-in default'
357export const DEFAULT_RULE_ID = 'noNewFiles:report-files'
358
359/** The one rule that holds with no rules file: no new report-style markdown outside docs/. */
360export const defaultRules = (): RuleSet => ({
361  ...emptyRules(),
362  noNewFiles: [
363    {
364      kind: 'noNewFiles',
365      id: DEFAULT_RULE_ID,
366      match: { type: 'report-words' },
367      except: ['docs/**', '.claude/**', 'README.md', 'CHANGELOG.md', 'CONTRIBUTING.md'],
368      why: 'report-style markdown (SUMMARY, REPORT, NOTES, IMPLEMENTATION, ANALYSIS, CHANGES, PLAN) is not wanted outside docs/',
369      source: DEFAULT_SOURCE,
370    },
371  ],
372})
373
374const isRecord = (value: unknown): value is Record<string, unknown> =>
375  typeof value === 'object' && value !== null && !Array.isArray(value)
376
377const describe = (value: unknown): string => (Array.isArray(value) ? 'a list' : value === null ? 'null' : typeof value)
378
379type Entry = { pattern: string; why: string; except: string[] }
380
381/** One `protect` / `noNewFiles` / `commands` entry: a bare string, or an object with its key and an optional why. */
382const entryOf = (
383  value: unknown,
384  where: string,
385  key: 'glob' | 'match',
386  allowsExcept: boolean,
387  fallbackWhy: string,
388  errors: string[],
389): Entry | null => {
390  if (typeof value === 'string') {
391    return { pattern: value, why: fallbackWhy, except: [] }
392  }
393
394  if (!isRecord(value)) {
395    errors.push(`${where} must be a string or an object with "${key}", got ${describe(value)}`)
396
397    return null
398  }
399
400  const allowed = new Set([key, 'why', ...(allowsExcept ? ['except'] : [])])
401
402  for (const field of Object.keys(value)) {
403    if (!field.startsWith('$') && !allowed.has(field)) {
404      errors.push(`${where} has an unknown field "${field}" (allowed: ${[...allowed].join(', ')})`)
405    }
406  }
407
408  const pattern = value[key]
409
410  if (typeof pattern !== 'string') {
411    errors.push(`${where}.${key} must be a string`)
412
413    return null
414  }
415
416  const why = value.why
417
418  if (why !== undefined && (typeof why !== 'string' || why.trim() === '')) {
419    errors.push(`${where}.why must be a non-empty string`)
420  }
421
422  const except: string[] = []
423  const rawExcept = value.except
424
425  if (rawExcept !== undefined) {
426    if (!Array.isArray(rawExcept)) {
427      errors.push(`${where}.except must be a list of globs`)
428    } else {
429      rawExcept.forEach((item: unknown, index) => {
430        const problem = typeof item === 'string' ? globProblem(item) : 'is not a string'
431
432        if (problem === null && typeof item === 'string') {
433          except.push(item)
434        } else {
435          errors.push(`${where}.except[${index}] ${problem ?? ''}`.trim())
436        }
437      })
438    }
439  }
440
441  return { pattern, why: typeof why === 'string' && why.trim() !== '' ? why.trim() : fallbackWhy, except }
442}
443
444const listOf = (data: Record<string, unknown>, field: string, source: string, errors: string[]): unknown[] => {
445  const value = data[field]
446
447  if (value === undefined) {
448    return []
449  }
450
451  if (!Array.isArray(value)) {
452    errors.push(`${source}: "${field}" must be a list, got ${describe(value)}`)
453
454    return []
455  }
456
457  return value
458}
459
460/** `isReadable` is false when the file is not a JSON object at all: then it counts as no file. */
461export type Parsed = { rules: RuleSet; errors: string[]; isReadable: boolean }
462
463/** Reads one rules file. Bad entries are reported and skipped; the good ones still apply. */
464export const parseRules = (text: string, source: string): Parsed => {
465  const rules = emptyRules()
466  const errors: string[] = []
467  let data: unknown
468
469  try {
470    data = JSON.parse(text)
471  } catch (error) {
472    const message = error instanceof Error ? error.message : String(error)
473
474    return { rules, errors: [`${source}: not valid JSON (${message})`], isReadable: false }
475  }
476
477  if (!isRecord(data)) {
478    return { rules, errors: [`${source}: must be a JSON object, got ${describe(data)}`], isReadable: false }
479  }
480
481  for (const field of Object.keys(data)) {
482    if (!field.startsWith('$') && !(KNOWN_FIELDS as readonly string[]).includes(field)) {
483      errors.push(`${source}: unknown field "${field}" (known: ${KNOWN_FIELDS.join(', ')})`)
484    }
485  }
486
487  listOf(data, 'protect', source, errors).forEach((value, index) => {
488    const where = `${source}: protect[${index}]`
489    const entry = entryOf(value, where, 'glob', false, GENERIC_WHY.protect, errors)
490    const problem = entry === null ? null : globProblem(entry.pattern)
491
492    if (entry !== null && problem !== null) {
493      errors.push(`${where} "${entry.pattern}" ${problem}`)
494    } else if (entry !== null) {
495      rules.protect.push({ kind: 'protect', id: `protect:${entry.pattern}`, glob: entry.pattern, why: entry.why, source })
496    }
497  })
498
499  listOf(data, 'noNewFiles', source, errors).forEach((value, index) => {
500    const where = `${source}: noNewFiles[${index}]`
501    const entry = entryOf(value, where, 'glob', true, GENERIC_WHY.noNewFiles, errors)
502    const problem = entry === null ? null : globProblem(entry.pattern)
503
504    if (entry !== null && problem !== null) {
505      errors.push(`${where} "${entry.pattern}" ${problem}`)
506    } else if (entry !== null) {
507      rules.noNewFiles.push({
508        kind: 'noNewFiles',
509        id: `noNewFiles:${entry.pattern}`,
510        match: { type: 'glob', glob: entry.pattern },
511        except: entry.except,
512        why: entry.why,
513        source,
514      })
515    }
516  })
517
518  listOf(data, 'commands', source, errors).forEach((value, index) => {
519    const where = `${source}: commands[${index}]`
520    const entry = entryOf(value, where, 'match', false, GENERIC_WHY.commands, errors)
521
522    if (entry === null) {
523      return
524    }
525
526    try {
527      new RegExp(entry.pattern)
528      rules.commands.push({ kind: 'commands', id: `commands:${entry.pattern}`, match: entry.pattern, why: entry.why, source })
529    } catch (error) {
530      const message = error instanceof Error ? error.message : String(error)
531      errors.push(`${where}.match is not a valid regular expression (${message})`)
532    }
533  })
534
535  listOf(data, 'pin', source, errors).forEach((value, index) => {
536    if (typeof value === 'string' && value.trim() !== '') {
537      rules.pin.push(value.trim())
538    } else {
539      errors.push(`${source}: pin[${index}] must be a non-empty string`)
540    }
541  })
542
543  const every = data.pinEvery
544
545  if (every !== undefined) {
546    if (typeof every === 'number' && Number.isInteger(every) && every >= 0) {
547      rules.pinEvery = every
548    } else {
549      errors.push(`${source}: pinEvery must be a whole number of prompts (0 pins only after compaction)`)
550    }
551  }
552
553  return { rules, errors, isReadable: true }
554}
555
556const byId = <T extends { id: string }>(lists: readonly T[][]): T[] => {
557  const merged = new Map<string, T>()
558
559  for (const list of lists) {
560    for (const rule of list) {
561      merged.set(rule.id, rule)
562    }
563  }
564
565  return [...merged.values()]
566}
567
568/** Merges rule sets in order; a later set (the project's) wins a rule with the same pattern, and pinEvery. */
569export const mergeRules = (sets: readonly RuleSet[]): RuleSet => ({
570  protect: byId(sets.map(set => set.protect)),
571  noNewFiles: byId(sets.map(set => set.noNewFiles)),
572  commands: byId(sets.map(set => set.commands)),
573  pin: [...new Set(sets.flatMap(set => set.pin))],
574  pinEvery: sets.reduce<number | null>((value, set) => set.pinEvery ?? value, null),
575})
576
577export const pinEveryOf = (rules: RuleSet): number => rules.pinEvery ?? DEFAULT_PIN_EVERY
578
579// ---------------------------------------------------------------- shell
580
581/** Index of the ')' closing the '(' just before `from`, quotes respected; the end when none. */
582const closingParen = (text: string, from: number): number => {
583  let depth = 1
584  let quote: string | null = null
585
586  for (let index = from; index < text.length; index++) {
587    const char = text[index]
588
589    if (quote !== null) {
590      if (char === '\\' && quote === '"') {
591        index++
592      } else if (char === quote) {
593        quote = null
594      }
595    } else if (char === '\\') {
596      index++
597    } else if (char === '"' || char === "'") {
598      quote = char
599    } else if (char === '(') {
600      depth++
601    } else if (char === ')') {
602      depth--
603
604      if (depth === 0) {
605        return index
606      }
607    }
608  }
609
610  return text.length
611}
612
613const HEREDOC = /^<<(-?)\s*(['"]?)([A-Za-z_][\w-]*)\2/
614
615/**
616 * Splits a shell command into the simple commands it runs: at `&&`, `||`,
617 * `;`, `|`, `&` and newlines outside quotes. Heredoc bodies are skipped, and
618 * the insides of `$(...)` and backticks are added as commands of their own.
619 */
620export const splitSegments = (command: string): string[] => {
621  const segments: string[] = []
622  const nested: string[] = []
623  const heredocs: { delimiter: string; isIndented: boolean }[] = []
624  let current = ''
625  let quote: '"' | "'" | null = null
626  let index = 0
627
628  const flush = () => {
629    if (current.trim() !== '') {
630      segments.push(current.trim())
631    }
632
633    current = ''
634  }
635
636  while (index < command.length) {
637    const char = command[index] ?? ''
638    const pair = command.slice(index, index + 2)
639
640    if (quote === "'") {
641      current += char
642      quote = char === "'" ? null : quote
643      index++
644      continue
645    }
646
647    if (char === '\\') {
648      current += pair
649      index += 2
650      continue
651    }
652
653    if (pair === '$(' && command[index + 2] !== '(') {
654      const end = closingParen(command, index + 2)
655      nested.push(...splitSegments(command.slice(index + 2, end)))
656      current += command.slice(index, end + 1)
657      index = end + 1
658      continue
659    }
660
661    if (char === '`') {
662      const found = command.indexOf('`', index + 1)
663      const end = found === -1 ? command.length : found
664      nested.push(...splitSegments(command.slice(index + 1, end)))
665      current += command.slice(index, end + 1)
666      index = end + 1
667      continue
668    }
669
670    if (quote === '"') {
671      current += char
672      quote = char === '"' ? null : quote
673      index++
674      continue
675    }
676
677    if (char === '"' || char === "'") {
678      quote = char
679      current += char
680      index++
681      continue
682    }
683
684    const heredoc = HEREDOC.exec(command.slice(index))
685
686    if (heredoc !== null && command[index + 2] !== '<') {
687      heredocs.push({ delimiter: heredoc[3] ?? '', isIndented: heredoc[1] === '-' })
688      current += heredoc[0]
689      index += heredoc[0].length
690      continue
691    }
692
693    if (char === '\n') {
694      flush()
695      index++
696
697      for (const { delimiter, isIndented } of heredocs.splice(0)) {
698        while (index < command.length) {
699          const lineEnd = command.indexOf('\n', index)
700          const end = lineEnd === -1 ? command.length : lineEnd
701          const line = command.slice(index, end)
702          index = end + 1
703
704          if ((isIndented ? line.replace(/^\t+/, '') : line) === delimiter) {
705            break
706          }
707        }
708      }
709
710      continue
711    }
712
713    if (pair === '&&' || pair === '||') {
714      flush()
715      index += 2
716      continue
717    }
718
719    const isRedirectAmp = char === '&' && (command[index - 1] === '>' || command[index + 1] === '>')
720
721    if (char === ';' || char === '|' || (char === '&' && !isRedirectAmp)) {
722      flush()
723      index++
724      continue
725    }
726
727    current += char
728    index++
729  }
730
731  flush()
732
733  return [...segments, ...nested]
734}
735
736/** A shell word with its quotes removed; `hasGlob` / `hasVariable` say whether the shell would rewrite it. */
737export type Word = { text: string; isOperator: boolean; isQuoted: boolean; hasGlob: boolean; hasVariable: boolean }
738
739const word = (text: string, flags: Partial<Omit<Word, 'text'>> = {}): Word => ({
740  text,
741  isOperator: false,
742  isQuoted: false,
743  hasGlob: false,
744  hasVariable: false,
745  ...flags,
746})
747
748/** The words of one simple command; redirection operators (`>`, `2>>`, `&>`, `>&2`) are words of their own. */
749export const wordsOf = (segment: string): Word[] => {
750  const words: Word[] = []
751  let text = ''
752  let isStarted = false
753  let isQuoted = false
754  let hasGlob = false
755  let hasVariable = false
756  let index = 0
757
758  const push = () => {
759    if (isStarted) {
760      words.push(word(text, { isQuoted, hasGlob, hasVariable }))
761    }
762
763    text = ''
764    isStarted = false
765    isQuoted = false
766    hasGlob = false
767    hasVariable = false
768  }
769
770  while (index < segment.length) {
771    const char = segment[index] ?? ''
772
773    if (/\s/.test(char) || char === '(' || char === ')') {
774      push()
775      index++
776    } else if (char === "'") {
777      const found = segment.indexOf("'", index + 1)
778      const end = found === -1 ? segment.length : found
779      text += segment.slice(index + 1, end)
780      isStarted = true
781      isQuoted = true
782      index = end + 1
783    } else if (char === '"') {
784      let cursor = index + 1
785
786      while (cursor < segment.length && segment[cursor] !== '"') {
787        if (segment[cursor] === '\\' && cursor + 1 < segment.length) {
788          cursor++
789        } else if (segment[cursor] === '$' || segment[cursor] === '`') {
790          hasVariable = true
791        }
792
793        text += segment[cursor] ?? ''
794        cursor++
795      }
796
797      isStarted = true
798      isQuoted = true
799      index = cursor + 1
800    } else if (char === '\\') {
801      text += segment[index + 1] ?? ''
802      isStarted = true
803      index += 2
804    } else if (char === '$' && segment[index + 1] === '(') {
805      const end = closingParen(segment, index + 2)
806      text += segment.slice(index, end + 1)
807      isStarted = true
808      hasVariable = true
809      index = end + 1
810    } else if (char === '`') {
811      const found = segment.indexOf('`', index + 1)
812      const end = found === -1 ? segment.length : found
813      text += segment.slice(index, end + 1)
814      isStarted = true
815      hasVariable = true
816      index = end + 1
817    } else if (char === '>' || char === '<') {
818      let operator = ''
819
820      if (isStarted && !isQuoted && /^(\d+|&)$/.test(text)) {
821        operator = text
822        text = ''
823        isStarted = false
824      } else {
825        push()
826      }
827
828      let cursor = index
829
830      while (cursor < segment.length && (segment[cursor] === '>' || segment[cursor] === '<')) {
831        cursor++
832      }
833
834      operator += segment.slice(index, cursor)
835
836      if (segment[cursor] === '|' && operator.endsWith('>')) {
837        operator += '|'
838        cursor++
839      }
840
841      if (segment[cursor] === '&') {
842        const duplicate = /^&(\d+|-)/.exec(segment.slice(cursor))
843        const taken = duplicate === null ? '&' : duplicate[0]
844        operator += taken
845        cursor += taken.length
846      }
847
848      words.push(word(operator, { isOperator: true }))
849      index = cursor
850    } else {
851      if (char === '*' || char === '?' || char === '[') {
852        hasGlob = true
853      }
854
855      if (char === '$') {
856        hasVariable = true
857      }
858
859      text += char
860      isStarted = true
861      index++
862    }
863  }
864
865  push()
866
867  return words.filter(one => one.isOperator || one.isQuoted || (one.text !== '{' && one.text !== '}'))
868}
869
870const SPECIAL_FILES = /^\/dev\/(null|stdout|stderr|tty|fd\/\d+)$/
871
872type Redirect = { operator: string; target: Word }
873
874/** The command's own words, and what its redirections point at. */
875const splitRedirects = (words: readonly Word[]): { argv: Word[]; redirects: Redirect[] } => {
876  const argv: Word[] = []
877  const redirects: Redirect[] = []
878
879  for (let index = 0; index < words.length; index++) {
880    const current = words[index]
881
882    if (current === undefined) {
883      continue
884    }
885
886    if (!current.isOperator) {
887      argv.push(current)
888      continue
889    }
890
891    const isDuplicate = /&(\d+|-)$/.test(current.text)
892    const target = words[index + 1]
893
894    if (!isDuplicate && target !== undefined && !target.isOperator) {
895      redirects.push({ operator: current.text, target })
896      index++
897    }
898  }
899
900  return { argv, redirects }
901}
902
903const WRAPPER_VALUE_OPTIONS: Readonly<Record<string, readonly string[]>> = {
904  sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-U'],
905  doas: ['-u', '-C'],
906  env: ['-u', '-C', '-S'],
907  nice: ['-n'],
908  ionice: ['-c', '-n', '-p'],
909  timeout: ['-s', '-k'],
910  stdbuf: ['-i', '-o', '-e'],
911  command: [],
912  builtin: [],
913  nohup: [],
914  time: [],
915  exec: ['-a'],
916}
917
918const isAssignment = (one: Word): boolean => !one.isQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(one.text)
919
920/** Drops leading `VAR=value` words and wrappers like `sudo`, `env`, `nice -n 5`, `timeout 10`. */
921export const stripWrappers = (argv: readonly Word[]): Word[] => {
922  let rest = [...argv]
923
924  for (;;) {
925    const head = rest[0]
926
927    if (head === undefined) {
928      return rest
929    }
930
931    if (isAssignment(head)) {
932      rest = rest.slice(1)
933      continue
934    }
935
936    const name = baseName(head.text)
937    const valueOptions = WRAPPER_VALUE_OPTIONS[name]
938
939    if (valueOptions === undefined) {
940      return rest
941    }
942
943    let index = 1
944
945    while (index < rest.length) {
946      const current = rest[index]
947
948      if (current === undefined || !(current.text.startsWith('-') || (name === 'env' && isAssignment(current)))) {
949        break
950      }
951
952      index += valueOptions.includes(current.text) ? 2 : 1
953
954      if (current.text === '--') {
955        break
956      }
957    }
958
959    if (name === 'timeout') {
960      index++
961    }
962
963    rest = rest.slice(index)
964  }
965}
966
967/** The operands of a command: words that are not options, nor the values of `valueOptions`; all after `--`. */
968const operandsOf = (args: readonly Word[], valueOptions: readonly string[] = []): Word[] => {
969  const operands: Word[] = []
970  let isPastOptions = false
971
972  for (let index = 0; index < args.length; index++) {
973    const current = args[index]
974
975    if (current === undefined) {
976      continue
977    }
978
979    if (isPastOptions || !current.text.startsWith('-') || current.text === '-') {
980      operands.push(current)
981    } else if (current.text === '--') {
982      isPastOptions = true
983    } else if (valueOptions.includes(current.text)) {
984      index++
985    }
986  }
987
988  return operands
989}
990
991const optionValue = (args: readonly Word[], names: readonly string[]): Word | undefined => {
992  const index = args.findIndex(current => names.includes(current.text))
993
994  return index === -1 ? undefined : args[index + 1]
995}
996
997/** A path the command writes, deletes or moves; `intoDir` means "only if that path is a folder". */
998export type RawTarget = { word: Word; mayCreate: boolean; intoDir?: Word }
999
1000const copyLike = (args: readonly Word[], valueOptions: readonly string[], movesSources: boolean): RawTarget[] => {
1001  const operands = operandsOf(args, valueOptions)
1002  const named = optionValue(args, ['-t', '--target-directory'])
1003  const sources = named === undefined ? operands.slice(0, -1) : operands
1004  const destination = named ?? operands[operands.length - 1]
1005
1006  if (destination === undefined || sources.length === 0) {
1007    return []
1008  }
1009
1010  const moved = movesSources ? sources.map(source => ({ word: source, mayCreate: false })) : []
1011  const intoDir = sources.map(source => ({
1012    word: word(baseName(source.text.replace(/\/+$/, '')), { hasGlob: source.hasGlob, hasVariable: source.hasVariable }),
1013    mayCreate: true,
1014    intoDir: destination,
1015  }))
1016  const asFile = named === undefined && sources.length === 1 ? [{ word: destination, mayCreate: true }] : []
1017
1018  return [...moved, ...asFile, ...intoDir]
1019}
1020
1021/** `-i`, `-i.bak`, `-Ei`, `--in-place`: the bundled flags before the `i` are the ones each tool takes without a value. */
1022const isInPlace = (args: readonly Word[], bundled: RegExp): boolean =>
1023  args.some(current => current.text === '--in-place' || current.text.startsWith('--in-place=') || bundled.test(current.text))
1024
1025const SED_IN_PLACE = /^-[nErsuz]*i/
1026const PERL_IN_PLACE = /^-[pnlaws0-9]*i/
1027/** A perl option that ends in `e` (`-e`, `-pe`) takes the script as the next word; `-ie` is `-i` with suffix `e`. */
1028const PERL_SCRIPT_OPTION = /^-[A-Za-hj-z0-9]*[eE]$/
1029
1030const sedTargets = (args: readonly Word[]): RawTarget[] => {
1031  if (!isInPlace(args, SED_IN_PLACE)) {
1032    return []
1033  }
1034
1035  // BSD sed spells "no backup" as `-i ''`: that empty word is the suffix, not the script.
1036  const cleaned = args.filter((current, index) => !(current.isQuoted && current.text === '' && args[index - 1]?.text === '-i'))
1037  const operands = operandsOf(cleaned, ['-e', '--expression', '-f', '--file', '-l'])
1038  const hasScriptOption = cleaned.some(current => ['-e', '--expression', '-f', '--file'].includes(current.text))
1039
1040  return (hasScriptOption ? operands : operands.slice(1)).map(current => ({ word: current, mayCreate: false }))
1041}
1042
1043const perlTargets = (args: readonly Word[]): RawTarget[] => {
1044  if (!isInPlace(args, PERL_IN_PLACE)) {
1045    return []
1046  }
1047
1048  const operands: Word[] = []
1049  let hasScript = false
1050
1051  for (let index = 0; index < args.length; index++) {
1052    const current = args[index]
1053
1054    if (current === undefined) {
1055      continue
1056    }
1057
1058    if (PERL_SCRIPT_OPTION.test(current.text)) {
1059      hasScript = true
1060      index++
1061    } else if (!current.text.startsWith('-')) {
1062      operands.push(current)
1063    }
1064  }
1065
1066  return (hasScript ? operands : operands.slice(1)).map(current => ({ word: current, mayCreate: false }))
1067}
1068
1069const gitTargets = (args: readonly Word[]): RawTarget[] => {
1070  let index = 0
1071
1072  while (index < args.length && (args[index]?.text ?? '').startsWith('-')) {
1073    index += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[index]?.text ?? '') ? 2 : 1
1074  }
1075
1076  const sub = args[index]?.text
1077  const rest = args.slice(index + 1)
1078
1079  switch (sub) {
1080    case 'rm':
1081      return operandsOf(rest).map(current => ({ word: current, mayCreate: false }))
1082    case 'mv':
1083      return copyLike(rest, [], true)
1084    case 'restore':
1085      return operandsOf(rest, ['-s', '--source']).map(current => ({ word: current, mayCreate: false }))
1086    case 'checkout': {
1087      const dashes = rest.findIndex(current => current.text === '--')
1088
1089      return dashes === -1 ? [] : rest.slice(dashes + 1).map(current => ({ word: current, mayCreate: false }))
1090    }
1091    default:
1092      return []
1093  }
1094}
1095
1096/** What one command (wrappers already stripped) writes, deletes or moves, by its name. */
1097const commandTargets = (argv: readonly Word[]): RawTarget[] => {
1098  const name = baseName(argv[0]?.text ?? '')
1099  const args = argv.slice(1)
1100  const each = (list: Word[], mayCreate: boolean): RawTarget[] => list.map(current => ({ word: current, mayCreate }))
1101
1102  switch (name) {
1103    case 'rm':
1104    case 'unlink':
1105    case 'rmdir':
1106      return each(operandsOf(args), false)
1107    case 'shred':
1108      return each(operandsOf(args, ['-n', '-s', '--iterations', '--size']), false)
1109    case 'touch':
1110      return each(operandsOf(args, ['-r', '-t', '-d', '--reference', '--date']), true)
1111    case 'truncate':
1112      return each(operandsOf(args, ['-s', '-r', '--size', '--reference']), true)
1113    case 'tee':
1114      return each(operandsOf(args), true)
1115    case 'mv':
1116      return copyLike(args, ['-t', '--target-directory', '-S', '--suffix'], true)
1117    case 'cp':
1118    case 'ln':
1119      return copyLike(args, ['-t', '--target-directory', '-S', '--suffix'], false)
1120    case 'install':
1121      return copyLike(args, ['-t', '--target-directory', '-m', '--mode', '-o', '--owner', '-g', '--group', '-S', '--suffix'], false)
1122    case 'rsync':
1123      return copyLike(args, ['-e', '--rsh', '--exclude', '--include'], false)
1124    case 'sed':
1125    case 'gsed':
1126      return sedTargets(args)
1127    case 'perl':
1128      return perlTargets(args)
1129    case 'dd':
1130      return args.filter(current => current.text.startsWith('of=')).map(current => ({ word: { ...current, text: current.text.slice('of='.length) }, mayCreate: true }))
1131    case 'git':
1132      return gitTargets(args)
1133    default:
1134      return []
1135  }
1136}
1137
1138const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
1139/** How deep `bash -c "eval '...'"` is read before giving up. */
1140const MAX_NESTING = 3
1141
1142/** A path a Bash command touches, absolute; `intoDir` set means it counts only when that folder exists. */
1143export type ShellTarget = { path: string; mayCreate: boolean; hasGlob: boolean; intoDir?: string }
1144
1145/** One simple command as typed, and with its wrappers and redirections taken off. */
1146export type ShellSegment = { raw: string; plain: string }
1147
1148export type ShellAnalysis = { segments: ShellSegment[]; targets: ShellTarget[] }
1149
1150/** The commands a Bash call runs and the paths it writes, `cd` followed from `cwd`. */
1151export const analyzeBash = (command: string, cwd: string, home?: string, depth = 0): ShellAnalysis => {
1152  const segments: ShellSegment[] = []
1153  const targets: ShellTarget[] = []
1154  let base = cwd
1155
1156  for (const raw of splitSegments(command)) {
1157    const { argv: words, redirects } = splitRedirects(wordsOf(raw))
1158    const argv = stripWrappers(words)
1159    const name = baseName(argv[0]?.text ?? '')
1160    segments.push({ raw, plain: argv.map(current => current.text).join(' ') })
1161
1162    const place = (current: Word): string | null =>
1163      current.hasVariable || current.text === '' ? null : resolvePath(base, current.text, current.isQuoted ? undefined : home)
1164
1165    for (const { operator, target } of redirects) {
1166      const path = operator.includes('>') && !SPECIAL_FILES.test(target.text) ? place(target) : null
1167
1168      if (path !== null) {
1169        targets.push({ path, mayCreate: true, hasGlob: target.hasGlob })
1170      }
1171    }
1172
1173    if (name === 'cd' || name === 'pushd') {
1174      const to = operandsOf(argv.slice(1))[0]
1175      const next = to === undefined ? (home ?? null) : to.text === '-' ? null : place(to)
1176      base = next ?? base
1177      continue
1178    }
1179
1180    if (depth < MAX_NESTING && SHELLS.has(name)) {
1181      const script = optionValue(argv, ['-c'])
1182
1183      if (script !== undefined) {
1184        const inner = analyzeBash(script.text, base, home, depth + 1)
1185        segments.push(...inner.segments)
1186        targets.push(...inner.targets)
1187      }
1188
1189      continue
1190    }
1191
1192    if (depth < MAX_NESTING && name === 'eval') {
1193      const inner = analyzeBash(argv.slice(1).map(current => current.text).join(' '), base, home, depth + 1)
1194      segments.push(...inner.segments)
1195      targets.push(...inner.targets)
1196      continue
1197    }
1198
1199    for (const target of commandTargets(argv)) {
1200      const path = place(target.word)
types/index.d.ts 43 lines
1export type ProtectRule = { kind: 'protect'; id: string; glob: string; why: string; source: string }
2
3/** A glob the user wrote, or the built-in report-file test, which matches whole words of the name. */
4export type NewFileMatch = { type: 'glob'; glob: string } | { type: 'report-words' }
5
6export type NoNewFilesRule = { kind: 'noNewFiles'; id: string; match: NewFileMatch; except: string[]; why: string; source: string }
7
8export type CommandRule = { kind: 'commands'; id: string; match: string; why: string; source: string }
9
10export type Rule = ProtectRule | NoNewFilesRule | CommandRule
11
12export type RuleSet = {
13  protect: ProtectRule[]
14  noNewFiles: NoNewFilesRule[]
15  commands: CommandRule[]
16  pin: string[]
17  /** Prompts between two pin injections; null while no file set it. */
18  pinEvery: number | null
19}
20
21export type LoadedRules = {
22  /** File mtimes and settings the rules were read under; a different key means read again. */
23  key: string
24  rules: RuleSet
25  /** The files the rules came from, as /house-rules names them. */
26  files: string[]
27  errors: string[]
28  isDefault: boolean
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'house-rules': {
34      loaded: LoadedRules | null
35      blocks: Record<string, number>
36      prompts: number
37      toasted: string[]
38      /** A compaction ran and the pinned rules have not been re-read since. */
39      pinPending: boolean
40    }
41  }
42}
43