SLOPSHOPPER

clean-code

Setzt Clean-Code-Regeln als Hooks um: Lint-Hinweis nach Code-Änderungen, Referenzordner mit Quellcode der Bibliotheken und Schutz für CLAUDE.md, AGENTS.md und…

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-code
› 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 › /ref-add ⎿ clean-code: Verwendung: /ref-add <git-url>, zum Beispiel https://github.com/owner/repo. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clean-code

Eine Mod für Claude Code, die Claude bei sauberem Code an den Regeln aus dem Video Sauberer Code mit Claude Code von Moritz Brandes ausrichtet. Sie erzwingt, was sich erzwingen lässt, und sagt Claude den Rest in jeder Session.

Version 0.1.0 · Autor Wilhelm Peters · Lizenz MIT


Was sie macht

  • Prüfhinweis. Nach einer Code-Änderung erinnert die Mod Claude daran, den Linter des Projekts laufen zu lassen und alle Befunde zu beheben. Den Befehl erkennt sie an den Konfigurationsdateien im Projekt, etwa an package.json mit einem lint-Skript, .oxlintrc.json, oxlint.config.ts (so schreibt es Ultracite), biome.json, pyproject.toml oder Cargo.toml.
  • Regeln im Systemprompt. Ein kurzer Abschnitt mit den Regeln R1 bis R9 wirkt in jeder Session. Er bleibt unter 1.200 Zeichen.
  • Referenzordner. Bibliotheken holst du als flache Checkouts ohne Historie in einen Ordner. Bei Fragen zu diesen Bibliotheken liest Claude dort zuerst den Quellcode.
  • Schutz für Anweisungsdateien. Die Mod kann CLAUDE.md, CLAUDE.local.md, AGENTS.md, SKILL.md und Dateien unter .claude/skills/, .claude/agents/ und .claude/rules/ davor schützen, dass Claude sie ohne Nachfrage ändert.

Nur R1, R4 und R8 haben zusätzlich einen Hook. Die übrigen Regeln wirken über den Systemprompt und sind damit Bitten an Claude, keine Prüfung.


Regeln aus dem Video

Regel aus dem VideoWas die Mod tut
R1 Nach Code-Änderung Linter laufen lassen und Befunde behebenHinweis nach Code-Änderungen mit dem erkannten Lint-Befehl, auch wenn Claude die Datei per Bash schreibt (siehe Grenzen). Ohne Linter folgt der Hinweis auf Typprüfung oder Tests und den Vorschlag, einen strengen Linter einzurichten.
R2 Streng typisieren (TypeScript statt JavaScript, strict an)Regel im Systemprompt. Dazu hängt die Mod an den Lint-Hinweis einen strict-Satz, wenn die tsconfig.json des Projekts "strict": true nicht setzt. Erbt die Datei per extends, kommt kein Hinweis, weil strict dort herkommen kann.
R3 Wiederkehrende Fehler als Lint-Regel, nicht als ProsaRegel im Systemprompt
R4 Referenzordner mit flachen Checkouts zuerst lesenBefehle /ref-add und /refs, Liste der Bibliotheken im Systemprompt
R5 Idiomatischer Code im Stil des FrameworksRegel im Systemprompt
R6 Agentenfreundliche, explizite Technik wählen und das Ökosystem prüfenRegel im Systemprompt
R7 Die eleganteste, kompakteste und lesbarste Lösung wählenRegel im Systemprompt
R8 CLAUDE.md, AGENTS.md und Skills nicht automatisch von Claude pflegen lassenNachfrage vor jeder Änderung, auch bei Bash-Befehlen, die die Datei schreiben. Im Rechtemodus auto oder bypassPermissions sieht niemand die Nachfrage, dort sperrt die Mod stattdessen. Einstellbar als Sperre oder ganz aus.
R9 Kontext sauber halten, kurze AnweisungenDer Regelabschnitt im Systemprompt bleibt unter 1.200 Zeichen.

Befehle

BefehlWirkung
/ref-add <git-url>Holt das Repository als flachen Checkout (git clone --depth 1) in den Referenzordner. Gibt es das Ziel schon, meldet die Mod das und klont nicht erneut. Erlaubt sind https://- und git@-Adressen.
/refsListet die Ordner im Referenzordner auf.

Einstellungen

EinstellungStandardBedeutung
referencesDir~/referencesOrdner für die flachen Checkouts
lintHinttruePrüfhinweis nach Code-Änderungen
guardInstructionFilesfragenSchutz für CLAUDE.md, AGENTS.md und Skills. fragen bedeutet, Claude fragt vor jeder Änderung. sperren verbietet die Änderung. aus schaltet den Schutz ab.

Die Einstellungen fragt Claude bei der Installation ab. Enter übernimmt jeweils den Standard.


Installation

Die Mod steckt im Marketplace willis-mods.

In einer Claude-Code-Session im Terminal:

/plugin install clean-code --marketplace ptrzptrz888-create/claude-mods

Die Frage „Add marketplace?" mit y bestätigen und als Scope „user" wählen. Danach zeigt Claude die Einstellungen der Mod, Enter übernimmt jeweils den Standard bis „Save configuration". Anschließend läuft die Mod in jeder neuen Session, auch in der Desktop-App.

Im Code-Tab der Desktop-App und in claude -p gibt es den Befehl /plugin nicht. Dort, oder ganz ohne laufende Session, installierst du mit diesen zwei Befehlen im Terminal:

claude plugin marketplace add ptrzptrz888-create/claude-mods
claude plugin install clean-code@willis-mods --scope user

Ergänzend von Hand

Die Mod übernimmt nicht alles. Vier Punkte musst du selbst erledigen.

  • Referenzordner füllen. Die Mod legt keine Bibliotheken an. Hole die Quellen selbst mit /ref-add <git-url> in den Ordner aus referencesDir.
  • Strengen Linter einrichten. Im Video ist von oxlint mit einem Preset die Rede. Gemeint ist Ultracite, ein Zero-Config-Preset für oxlint und Biome mit Antislop-Regeln. Die Untertitelspur schreibt es als „Ultraside“, die deutsche Originalspur bestätigt „Ultracite“. Dazu gehören TypeScript statt JavaScript und strict in der Konfiguration. Die Mod richtet das nicht ein.
  • 1-Mio.-Kontextfenster abschalten. Der Sprecher schaltet es per Umgebungsvariable ab und lässt Claude bei einer empfohlenen Grenze kompaktieren. Die Variable ist CLAUDE_CODE_DISABLE_1M_CONTEXT=1 (Quelle: code.claude.com/docs/en/env-vars) und begrenzt den Kontext auf 200K. Die Mod setzt sie nicht.
  • Beispiele des Sprechers. Ponytail (ein Skill gegen Overengineering) sowie Effect und Foldkit nennt der Sprecher als persönliche Wahl, laut deutscher Originalspur bestätigt. Effect empfiehlt er als Standardbibliothek für TypeScript, Foldkit als React-Alternative für Agenten. Das sind Beispiele des Sprechers, die Mod schreibt kein Framework vor.

Grenzen

  • Der Hinweis nach einer Code-Änderung ist nur eine Erinnerung. Der Linter läuft nicht automatisch, Claude muss ihn ausführen.
  • Der Schutz der Anweisungsdateien und der Lint-Hinweis greifen bei Edit, Write und MultiEdit und bei Bash-Befehlen, die erkennbar Dateien schreiben, etwa Umleitungen (>, >>), sed -i, tee, mv oder rm. Das ist eine Textanalyse des Befehls, keine Ausführung.
  • Nicht erkannt werden Schreibzugriffe, die erst zur Laufzeit entstehen, etwa python -c, node -e, Skripte, die selbst Dateien schreiben, und Befehlssubstitution wie $(…). Wer die Sperre umgehen will, kann das also weiterhin.
  • Eine Nachfrage (ask) geht an den Entscheider des Rechtemodus, im Modus auto an den Klassifizierer statt an die Person. Der hat in einem Test am 09.10.2026 eine Änderung an CLAUDE.md still durchgelassen. Deshalb sperrt die Mod in auto und bypassPermissions. Den Modus liest sie aus den klassischen Hooks UserPromptSubmit und PostToolUse, weil PreToolUse ihn nicht mitliefert. Wechselt der Modus mitten im Turn, gilt der neue erst ab dem nächsten Werkzeugaufruf.

Tests

claude plugin test .

bun test allein reicht hier nicht, weil die Tests das Modul claude-code/testing brauchen, das nur claude plugin test bereitstellt.

Source 3 files
hooks/register.ts 230 lines
1import type { EngineInterface, PromptComposeSection, Register } from 'claude-code'
2
3import {
4  detectLintCommand,
5  directoryNames,
6  expandHome,
7  instructionGuardText,
8  isCodeFile,
9  isInstructionFile,
10  isUnattendedMode,
11  lintHintText,
12  refsListText,
13  repoNameOf,
14  rulesSection,
15  strictHintText,
16  unattendedGuardText,
17  writtenPathsOf,
18} from './rules'
19import type { DirectoryEntry } from './rules'
20
21const DEFAULT_REFERENCES_DIR = '~/references'
22const CLONE_TIMEOUT_MS = 120_000
23const STDERR_LIMIT = 500
24const RULES_SECTION_ID = 'clean-code:rules'
25
26type GuardMode = 'fragen' | 'sperren' | 'aus'
27
28type Settings = {
29  readonly referencesDir: string
30  readonly isLintHintOn: boolean
31  readonly guardMode: GuardMode
32}
33
34type ProjectProbe = {
35  readonly command: string | undefined
36  readonly tsconfig: string | undefined
37}
38
39const asGuardMode = (value: unknown): GuardMode => (value === 'sperren' || value === 'aus' ? value : 'fragen')
40
41const settingsOf = (options: Record<string, unknown>): Settings => {
42  const referencesDir = String(options.referencesDir ?? '').trim()
43
44  return {
45    referencesDir: referencesDir === '' ? DEFAULT_REFERENCES_DIR : referencesDir,
46    isLintHintOn: options.lintHint !== false,
47    guardMode: asGuardMode(options.guardInstructionFiles),
48  }
49}
50
51// Module state starts over on a reload, which suits all three below.
52/** Ob der Lint-Hinweis in diesem Turn schon gegeben wurde. */
53let isHintGivenThisTurn = false
54/** Lint-Befehl und tsconfig der Session, einmal ermittelt. Beide Felder können `undefined` sein. */
55let lintCache: ProjectProbe | undefined
56/** Der zuletzt gemeldete Rechtemodus. classic.PreToolUse trägt ihn nicht, die übrigen klassischen Hooks schon. */
57let permissionMode: string | undefined
58
59/** Rückfrage mit `askText`, oder Sperre mit `denyText`, wenn im Modus niemand rückfragt. */
60const guardAnswer = (askText: string, denyText: string): { ask: string } | { deny: string } =>
61  isUnattendedMode(permissionMode) ? { deny: denyText } : { ask: askText }
62
63const referencesPath = async ($: EngineInterface, referencesDir: string): Promise<string> => {
64  const home = await $.env.get('HOME')
65
66  return home === undefined ? referencesDir : expandHome(referencesDir, home)
67}
68
69/** Die Einträge des Referenzordners, `undefined` wenn der Ordner fehlt. */
70const listEntries = async ($: EngineInterface, dir: string): Promise<DirectoryEntry[] | undefined> => {
71  if (!(await $.fs.exists(dir))) return undefined
72
73  return $.fs.list(dir)
74}
75
76/** Die Bibliotheksnamen für den Systemprompt. Fehler ergeben eine leere Liste und werden ins Debug-Log geschrieben. */
77const referenceNamesOf = async ($: EngineInterface, referencesDir: string): Promise<string[]> => {
78  try {
79    const entries = await listEntries($, await referencesPath($, referencesDir))
80
81    return entries === undefined ? [] : directoryNames(entries)
82  } catch (error) {
83    $.ui.log(`clean-code: Referenzordner nicht gelesen (${String(error)}).`, { to: 'debug' })
84
85    return []
86  }
87}
88
89const addReference = async ($: EngineInterface, referencesDir: string, args: string): Promise<string> => {
90  const url = args.trim()
91  if (url === '') return 'Verwendung: /ref-add <git-url>, zum Beispiel https://github.com/owner/repo.'
92
93  const name = repoNameOf(url)
94  if (name === undefined) return 'Ungültige Git-URL. Erlaubt sind https://… und git@…:… .'
95
96  const dir = await referencesPath($, referencesDir)
97  const target = `${dir}/${name}`
98  if (await $.fs.exists(target)) return `Schon vorhanden: ${target}. Zum Aktualisieren dort von Hand git pull ausführen.`
99
100  const result = await $.process.run(['git', 'clone', '--depth', '1', url, target], { timeoutMs: CLONE_TIMEOUT_MS })
101  if (result.exitCode !== 0) {
102    return `git clone endete mit Code ${result.exitCode}: ${result.stderr.trim().slice(0, STDERR_LIMIT)}`
103  }
104
105  return `Geholt: ${target}`
106}
107
108const listReferences = async ($: EngineInterface, referencesDir: string): Promise<string> => {
109  const dir = await referencesPath($, referencesDir)
110  const entries = await listEntries($, dir)
111
112  return refsListText(entries === undefined ? undefined : directoryNames(entries), dir)
113}
114
115/** Lint-Befehl und tsconfig, einmal pro Session ermittelt. Fehler ergeben leere Werte und den allgemeinen Hinweis. */
116const projectProbeOf = async ($: EngineInterface): Promise<ProjectProbe> => {
117  if (lintCache === undefined) {
118    lintCache = await detectProject($)
119  }
120
121  return lintCache
122}
123
124/** Der Text einer Datei im Projekt, `undefined` wenn sie nicht in der Liste steht. */
125const readIfListed = async ($: EngineInterface, files: readonly string[], name: string): Promise<string | undefined> =>
126  files.includes(name) ? $.fs.read(name) : undefined
127
128const detectProject = async ($: EngineInterface): Promise<ProjectProbe> => {
129  try {
130    const files = (await $.fs.list()).map(entry => entry.name)
131    const packageJson = await readIfListed($, files, 'package.json')
132    const tsconfig = await readIfListed($, files, 'tsconfig.json')
133
134    return { command: detectLintCommand({ packageJson, files }), tsconfig }
135  } catch (error) {
136    $.ui.log(`clean-code: Lint-Befehl nicht ermittelt (${String(error)}).`, { to: 'debug' })
137
138    return { command: undefined, tsconfig: undefined }
139  }
140}
141
142/** Lint-Hinweis, bei fehlendem strict um den Typhinweis ergänzt. */
143const lintContextOf = async ($: EngineInterface): Promise<string> => {
144  const { command, tsconfig } = await projectProbeOf($)
145  const strictHint = strictHintText(tsconfig)
146
147  return strictHint === undefined ? lintHintText(command) : `${lintHintText(command)} ${strictHint}`
148}
149
150export const register: Register = (on, options) => {
151  const settings = settingsOf(options)
152
153  on('session.start', async ($, e, next) => {
154    lintCache = undefined
155    await $.command.register({
156      name: 'ref-add',
157      description: 'Bibliothek als flachen Checkout in den Referenzordner holen',
158      argumentHint: '<git-url>',
159    })
160    await $.command.register({ name: 'refs', description: 'Referenzordner auflisten' })
161
162    return next(e)
163  })
164
165  on('turn.start', ($, e, next) => {
166    isHintGivenThisTurn = false
167
168    return next(e)
169  })
170
171  on('command.run', { command: 'ref-add' }, async ($, e) => ({
172    text: await addReference($, settings.referencesDir, e.args),
173  }))
174
175  on('command.run', { command: 'refs' }, async $ => ({
176    text: await listReferences($, settings.referencesDir),
177  }))
178
179  on('prompt.compose', async ($, e, next) => {
180    const composed = await next(e)
181    const section: PromptComposeSection = {
182      id: RULES_SECTION_ID,
183      text: rulesSection(await referenceNamesOf($, settings.referencesDir), settings.referencesDir),
184      scope: 'session',
185    }
186
187    return { sections: [...composed.sections, section] }
188  }).catch(($, e, next) => next(e))
189
190  on('tool.call', async ($, e, next) => {
191    const paths = writtenPathsOf(e)
192    if (paths.length === 0) return next(e)
193    const guarded = paths.find(isInstructionFile)
194    if (settings.guardMode === 'sperren' && guarded !== undefined) return { deny: instructionGuardText(guarded) }
195
196    const ran = await next(e)
197    const isHintDue = settings.isLintHintOn && paths.some(isCodeFile) && !isHintGivenThisTurn
198    if (!isHintDue || ran.deny !== undefined || ran.isError === true) return ran
199
200    isHintGivenThisTurn = true
201
202    return { ...ran, context: [...(ran.context ?? []), await lintContextOf($)] }
203  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'clean-code: Prüfung fehlgeschlagen.' }))
204
205  // Ein ask geht an den Entscheider des Modus, im Modus auto an den Klassifizierer. Deshalb den Modus mitlesen.
206  on('classic.UserPromptSubmit', ($, e, next) => {
207    permissionMode = e.permission_mode ?? permissionMode
208
209    return next(e)
210  }).catch(($, e, next) => next(e))
211
212  on('classic.PostToolUse', ($, e, next) => {
213    permissionMode = e.permission_mode ?? permissionMode
214
215    return next(e)
216  }).catch(($, e, next) => next(e))
217
218  // classic.PreToolUse trägt denselben ToolCallEnvelope wie tool.call, also auch tool und command bei Bash.
219  on('classic.PreToolUse', async ($, e, next) => {
220    const guarded = writtenPathsOf(e).find(isInstructionFile)
221    if (settings.guardMode !== 'fragen' || guarded === undefined) return next(e)
222
223    return guardAnswer(instructionGuardText(guarded), unattendedGuardText(guarded))
224  }).catch(($, e, next) =>
225    next.called
226      ? next(e)
227      : guardAnswer('clean-code: Prüfung fehlgeschlagen. Bitte die Änderung selbst bestätigen.', 'clean-code: Prüfung fehlgeschlagen.'),
228  )
229}
230
hooks/rules.ts 224 lines
1import { bashModifiedPaths } from './bash-paths'
2
3/** Dateiendungen, die als Code gelten und den Lint-Hinweis auslösen. */
4export const CODE_EXTENSIONS = [
5  'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts',
6  'py', 'go', 'rs', 'swift', 'kt', 'java', 'cs', 'php', 'rb', 'vue', 'svelte',
7] as const
8
9/** Obergrenze für den Systemprompt-Abschnitt (R9). */
10export const SECTION_LIMIT = 1200
11
12/** Dateinamen, die nur von Hand gepflegt werden sollen (R8). Kleingeschrieben. */
13const INSTRUCTION_FILE_NAMES: ReadonlySet<string> = new Set([
14  'claude.md', 'claude.local.md', 'agents.md', 'skill.md',
15])
16
17/** Ordner, deren Inhalt als Anweisung an Claude gilt (R8). */
18const INSTRUCTION_DIRS = ['/.claude/skills/', '/.claude/agents/', '/.claude/rules/'] as const
19
20const CODE_EXTENSION_SET: ReadonlySet<string> = new Set(CODE_EXTENSIONS)
21
22const WRITE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit'])
23
24const SCP_URL = /^git@[^:/\s]+:(.+)$/
25const REPO_NAME = /^[A-Za-z0-9._-]+$/
26const GIT_SUFFIX = /\.git$/
27
28const RULE_HEADING = '# Clean Code (Mod clean-code)'
29
30const RULE_LINES = [
31  '- R1 Nach jeder Code-Änderung den Linter laufen lassen und alle Befunde beheben.',
32  '- R2 Streng typisieren, TypeScript mit strict statt JavaScript.',
33  '- R3 Wiederkehrende Fehler als Lint-Regel festhalten, nicht als Prosa im Prompt.',
34  '- R4 Bei Fragen zu einer Bibliothek zuerst den Quellcode im Referenzordner oder in node_modules lesen.',
35  '- R5 Idiomatisch im Stil des Frameworks schreiben.',
36  '- R6 Agentenfreundliche, explizite Techniken wählen und das Ökosystem prüfen.',
37  '- R7 Die eleganteste, kompakteste und lesbarste Lösung wählen, nicht die aufgeblähte.',
38  '- R8 CLAUDE.md, AGENTS.md und Skills nicht ohne Freigabe der Person ändern.',
39] as const
40
41type LintRule = {
42  readonly when: (probe: LintProbe) => boolean
43  readonly command: string
44}
45
46export type LintProbe = {
47  readonly packageJson?: string
48  readonly files: readonly string[]
49}
50
51export type DirectoryEntry = {
52  readonly name: string
53  readonly kind: 'file' | 'dir' | 'other'
54}
55
56const isRecord = (value: unknown): value is Record<string, unknown> =>
57  typeof value === 'object' && value !== null && !Array.isArray(value)
58
59const baseNameOf = (path: string): string => path.split('/').pop()?.toLowerCase() ?? ''
60
61/** Ob die Datei Code ist, nach der Endung, Groß- und Kleinschreibung egal. */
62export const isCodeFile = (path: string): boolean => {
63  const extension = /\.([a-z]+)$/i.exec(path)?.[1]?.toLowerCase()
64
65  return extension !== undefined && CODE_EXTENSION_SET.has(extension)
66}
67
68/** Ob die Datei eine Anweisungsdatei ist, die nur von Hand geändert werden soll (R8). */
69export const isInstructionFile = (path: string): boolean => {
70  if (INSTRUCTION_FILE_NAMES.has(baseNameOf(path))) return true
71  const anchored = `/${path}`
72
73  return INSTRUCTION_DIRS.some(dir => anchored.includes(dir))
74}
75
76/** Ersetzt ein führendes `~` durch das Home-Verzeichnis. */
77export const expandHome = (path: string, home: string): string => {
78  if (path === '~') return home
79  if (path.startsWith('~/')) return `${home}${path.slice(1)}`
80
81  return path
82}
83
84/** Der Pfad einer https- oder git@-URL, sonst `undefined`. */
85const pathOf = (url: string): string | undefined => {
86  const scp = SCP_URL.exec(url)
87  if (scp?.[1] !== undefined) return scp[1]
88
89  try {
90    const parsed = new URL(url)
91
92    return parsed.protocol === 'https:' ? parsed.pathname : undefined
93  } catch {
94    return undefined
95  }
96}
97
98/** Der Name des Repos aus einer Git-URL, ohne `.git`. Unbekannte Formen ergeben `undefined`. */
99export const repoNameOf = (url: string): string | undefined => {
100  const path = pathOf(url)
101  if (path === undefined) return undefined
102
103  const segment = path.split('/').filter(part => part !== '').pop()
104  const name = segment?.replace(GIT_SUFFIX, '')
105  const isUsable = name !== undefined && name !== '.' && name !== '..' && REPO_NAME.test(name)
106
107  return isUsable ? name : undefined
108}
109
110const hasFile = (probe: LintProbe, name: string): boolean => probe.files.includes(name)
111
112/** Ob die package.json ein `scripts.lint` hat. Kaputtes JSON gilt als „nein“. */
113const hasLintScript = (packageJson: string | undefined): boolean => {
114  if (packageJson === undefined) return false
115
116  try {
117    const parsed: unknown = JSON.parse(packageJson)
118
119    return isRecord(parsed) && isRecord(parsed.scripts) && typeof parsed.scripts.lint === 'string'
120  } catch {
121    return false
122  }
123}
124
125const OXLINT_CONFIG_FILES: readonly string[] = [
126  '.oxlintrc.json', 'oxlint.config.ts', 'oxlint.config.mts', 'oxlint.config.js', 'oxlint.config.mjs',
127]
128
129const LINT_RULES: readonly LintRule[] = [
130  { when: probe => hasLintScript(probe.packageJson), command: 'npm run lint' },
131  { when: probe => OXLINT_CONFIG_FILES.some(file => hasFile(probe, file)), command: 'npx oxlint' },
132  { when: probe => hasFile(probe, 'biome.json') || hasFile(probe, 'biome.jsonc'), command: 'npx biome check' },
133  { when: probe => hasFile(probe, 'ruff.toml') || hasFile(probe, 'pyproject.toml'), command: 'ruff check' },
134  { when: probe => hasFile(probe, 'Cargo.toml'), command: 'cargo clippy' },
135  { when: probe => hasFile(probe, 'go.mod'), command: 'go vet ./...' },
136]
137
138/** Der Lint-Befehl des Projekts nach fester Reihenfolge, sonst `undefined`. */
139export const detectLintCommand = (probe: LintProbe): string | undefined =>
140  LINT_RULES.find(rule => rule.when(probe))?.command
141
142/** Der Hinweis nach einer Code-Änderung (R1). */
143export const lintHintText = (command?: string): string =>
144  command === undefined
145    ? 'Kein Linter erkannt. Vor dem Abschluss Typprüfung oder Tests laufen lassen und einen strengen Linter vorschlagen (etwa oxlint mit dem Preset Ultracite und strict).'
146    : `Code geändert. Vor dem Abschluss \`${command}\` ausführen und alle Befunde beheben. Wiederkehrende Fehler als Lint-Regel vorschlagen statt als Prosa.`
147
148const referenceText = (refs: readonly string[], refsDir: string, shownCount: number): string => {
149  if (refs.length === 0) return 'Referenzordner leer, mit /ref-add <git-url> füllen.'
150
151  const shown = refs.slice(0, shownCount).join(', ')
152  const rest = refs.length - shownCount
153  const tail = rest > 0 ? ` … und ${rest} weitere` : ''
154
155  return `Referenzordner ${refsDir}: ${shown}${tail}. Bei Fragen zu diesen Bibliotheken zuerst dort den Quellcode lesen.`
156}
157
158const sectionWith = (refs: readonly string[], refsDir: string, shownCount: number): string =>
159  [RULE_HEADING, ...RULE_LINES, referenceText(refs, refsDir, shownCount)].join('\n')
160
161/**
162 * Der Systemprompt-Abschnitt. Passt die volle Referenzliste nicht in
163 * SECTION_LIMIT, kürzt er die Liste mit „… und N weitere“.
164 */
165export const rulesSection = (refs: readonly string[], refsDir: string): string => {
166  for (let shownCount = refs.length; shownCount > 0; shownCount -= 1) {
167    const section = sectionWith(refs, refsDir, shownCount)
168    if (section.length <= SECTION_LIMIT) return section
169  }
170
171  return sectionWith(refs, refsDir, 0).slice(0, SECTION_LIMIT)
172}
173
174/** Die Begründung für Nachfrage bzw. Sperre einer Anweisungsdatei (R8). */
175export const instructionGuardText = (path: string): string =>
176  `Clean-Code-Regel R8 greift. ${path} wird von Hand gepflegt, nicht von Claude. Änderung nur nach ausdrücklicher Freigabe durch die Person.`
177
178/** Modi, in denen keine Person ein `ask` beantwortet: auto gibt es an den Klassifizierer, bypassPermissions fragt gar nicht. */
179const UNATTENDED_MODES: ReadonlySet<string> = new Set(['auto', 'bypassPermissions'])
180
181/** Ob im Rechtemodus `mode` niemand eine Rückfrage sieht. Ein unbekannter Modus zählt nicht dazu. */
182export const isUnattendedMode = (mode: string | undefined): boolean => mode !== undefined && UNATTENDED_MODES.has(mode)
183
184/** Die Begründung der Sperre, wenn im Modus niemand rückfragen kann (R8). */
185export const unattendedGuardText = (path: string): string =>
186  `${instructionGuardText(path)} In diesem Rechtemodus beantwortet niemand die Rückfrage, deshalb gesperrt. Die Person kann den Modus kurz auf Standard stellen oder die Datei selbst ändern.`
187
188/** Die Pfade, die ein Werkzeugaufruf schreibt. Bash wird über die Befehlsanalyse geprüft, Schreibwerkzeuge über `file_path`. Nimmt jeden Wert, weil die Tool-Union des Engines zu weit für einen engen Parameter ist. */
189export const writtenPathsOf = (call: unknown): string[] => {
190  if (!isRecord(call) || typeof call.tool !== 'string') return []
191  if (call.tool === 'Bash') return typeof call.command === 'string' ? bashModifiedPaths(call.command) : []
192  if (!WRITE_TOOLS.has(call.tool) || typeof call.file_path !== 'string') return []
193
194  return [call.file_path]
195}
196
197const STRICT_HINT = 'tsconfig.json hat kein "strict": true. Für TypeScript strict aktivieren (R2).'
198
199/** Per Regex statt JSON.parse, weil tsconfig Kommentare erlaubt. Bei `extends` kann strict geerbt sein, dann kein Hinweis. */
200const isStrictEnabled = (tsconfig: string): boolean =>
201  /"extends"\s*:/.test(tsconfig) || /"strict"\s*:\s*true/.test(tsconfig)
202
203/** Der Hinweis, wenn die tsconfig strict nicht auf true setzt. `undefined` heißt: kein Hinweis nötig oder keine tsconfig vorhanden. */
204export const strictHintText = (tsconfig: string | undefined): string | undefined => {
205  if (tsconfig === undefined) return undefined
206
207  return isStrictEnabled(tsconfig) ? undefined : STRICT_HINT
208}
209
210/** Die Namen der Unterordner, alphabetisch sortiert. Dateien fallen weg. */
211export const directoryNames = (entries: readonly DirectoryEntry[]): string[] =>
212  entries
213    .filter(entry => entry.kind === 'dir')
214    .map(entry => entry.name)
215    .sort((a, b) => a.localeCompare(b))
216
217/** Die Antwort des Befehls `/refs`. `undefined` heißt: der Ordner fehlt. */
218export const refsListText = (names: readonly string[] | undefined, refsDir: string): string => {
219  if (names === undefined) return `Referenzordner ${refsDir} fehlt. Mit /ref-add <git-url> anlegen.`
220  if (names.length === 0) return `Referenzordner ${refsDir} ist leer. Mit /ref-add <git-url> füllen.`
221
222  return `Referenzordner ${refsDir}:\n${names.map(name => `- ${name}`).join('\n')}`
223}
224
hooks/bash-paths.ts 278 lines
1/**
2 * Erkennt, welche Pfade ein Bash-Befehl schreibt, anlegt, verschiebt oder löscht.
3 *
4 * Kein vollständiger Shell-Parser. Bekannte Grenzen:
5 * - Erkannt werden tee, sed -i, perl -i, cp, install, mv, rm, touch sowie die Umleitungen
6 *   `>`, `>>`, `&>` und `>|`. Alles andere (python -c, node, dd of=, git apply, Editoren) bleibt unerkannt.
7 * - Befehlsersetzungen `$(...)` und Backticks gelten als Trennung. Verschachtelte Ersetzungen
8 *   innerhalb doppelter Anführungszeichen werden nicht ausgewertet.
9 * - Keine Auflösung von Variablen, Glob-Mustern oder `~`: `rm *.ts` liefert `*.ts`.
10 * - cp -t und mv -t (Zielordner vorab) liefern das falsche Ziel. install -d meldet nur das letzte Argument.
11 * - mv meldet Quelle und Ziel, weil auch die Quelle verschwindet.
12 * - Die Heredoc-Erkennung kennt keinen Anführungskontext: `echo "<<EOF"` startet einen Heredoc.
13 * - Umleitungen auf /dev/... und Eingabeumleitungen zählen nicht.
14 */
15
16/** Ein Wort mit seiner Position im Befehl, damit Ziele in Textreihenfolge erscheinen. */
17type Word = { readonly value: string; readonly pos: number }
18
19/** Ein einfacher Befehl: seine Argumente und die Ziele seiner Umleitungen. */
20type Command = { readonly words: readonly Word[]; readonly writes: readonly Word[] }
21
22type Token =
23  | { readonly kind: 'word'; readonly value: string }
24  | { readonly kind: 'redirect'; readonly mode: 'write' | 'skip' }
25  | { readonly kind: 'sep' }
26
27const HEREDOC = /(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_]\w*)\2/g
28const ASSIGNMENT = /^[A-Za-z_]\w*=/
29const DIGITS = /^\d+$/
30const SED_IN_PLACE = /^-[nErsuz]*i/
31const PERL_IN_PLACE = /^-[pnlaFs0-9]*i/
32const SCRIPT_FLAGS: ReadonlySet<string> = new Set(['-e', '-f'])
33const SEPARATOR_CHARS: ReadonlySet<string> = new Set([';', '\n', '(', ')', '`'])
34const DEVICE_PREFIX = '/dev/'
35
36const isOption = (value: string): boolean => value.startsWith('-') && value !== '-'
37
38const basenameOf = (value: string): string => value.split('/').pop() ?? value
39
40/** Entfernt Heredoc-Inhalte, behält aber die Kopfzeile samt Umleitungen. */
41const stripHeredocBodies = (command: string): string => {
42  const pending: { readonly delimiter: string; readonly stripTabs: boolean }[] = []
43  const kept: string[] = []
44
45  for (const line of command.split('\n')) {
46    const open = pending[0]
47    if (open !== undefined) {
48      const candidate = open.stripTabs ? line.replace(/^\t+/, '') : line
49      if (candidate === open.delimiter) pending.shift()
50      continue
51    }
52    for (const match of line.matchAll(HEREDOC)) {
53      pending.push({ delimiter: match[3] ?? '', stripTabs: match[1] === '-' })
54    }
55    kept.push(line.replace(HEREDOC, ' '))
56  }
57
58  return kept.join('\n')
59}
60
61/** Liest einen Anführungsblock ab `start`. `end` zeigt auf das schließende Zeichen. */
62const scanQuoted = (text: string, start: number): { readonly content: string; readonly end: number } => {
63  const isSingle = text.charAt(start) === "'"
64  const closing = isSingle ? "'" : '"'
65  let content = ''
66  let index = start + 1
67
68  while (index < text.length && text.charAt(index) !== closing) {
69    const char = text.charAt(index)
70    if (!isSingle && char === '\\' && index + 1 < text.length) {
71      const escaped = text.charAt(index + 1)
72      content += '"\\$`\n'.includes(escaped) ? escaped : `\\${escaped}`
73      index += 2
74    } else {
75      content += char
76      index += 1
77    }
78  }
79
80  return { content, end: index }
81}
82
83/** Zerlegt den Text in Wörter, Umleitungen und Trenner. Anführungszeichen und Kommentare werden beachtet. */
84const tokenize = (text: string): Token[] => {
85  const tokens: Token[] = []
86  let buffer = ''
87  let hasBuffer = false
88
89  const flush = (): void => {
90    if (hasBuffer) tokens.push({ kind: 'word', value: buffer })
91    buffer = ''
92    hasBuffer = false
93  }
94  const add = (token: Token): void => {
95    flush()
96    tokens.push(token)
97  }
98
99  for (let index = 0; index < text.length; index += 1) {
100    const char = text.charAt(index)
101    const next = text.charAt(index + 1)
102
103    if (char === ' ' || char === '\t' || char === '\r') {
104      flush()
105    } else if (char === '#' && !hasBuffer) {
106      const lineEnd = text.indexOf('\n', index)
107      if (lineEnd === -1) break
108      index = lineEnd - 1
109    } else if (char === '\\') {
110      if (next !== '\n' && next !== '') {
111        buffer += next
112        hasBuffer = true
113      }
114      index += 1
115    } else if (char === "'" || char === '"') {
116      const quoted = scanQuoted(text, index)
117      buffer += quoted.content
118      hasBuffer = true
119      index = quoted.end
120    } else if (char === '>') {
121      if (hasBuffer && DIGITS.test(buffer)) {
122        buffer = ''
123        hasBuffer = false
124      } else {
125        flush()
126      }
127      const writeEnd = next === '>' ? index + 1 : index
128      const marker = text.charAt(writeEnd + 1)
129      tokens.push({ kind: 'redirect', mode: marker === '&' ? 'skip' : 'write' })
130      index = writeEnd + (marker === '&' || marker === '|' ? 1 : 0)
131    } else if (char === '&' && next === '>') {
132      add({ kind: 'redirect', mode: 'write' })
133      index += text.charAt(index + 2) === '>' ? 2 : 1
134    } else if (char === '&' || char === '|') {
135      add({ kind: 'sep' })
136      if (next === char) index += 1
137    } else if (char === '<') {
138      add({ kind: 'redirect', mode: 'skip' })
139      while (text.charAt(index + 1) === '<') index += 1
140      if (text.charAt(index + 1) === '&') index += 1
141    } else if (SEPARATOR_CHARS.has(char)) {
142      add({ kind: 'sep' })
143    } else {
144      buffer += char
145      hasBuffer = true
146    }
147  }
148
149  flush()
150
151  return tokens
152}
153
154/** Gruppiert die Tokens in einfache Befehle. Die Position eines Wortes ist seine Stelle im Token-Strom. */
155const toCommands = (tokens: readonly Token[]): Command[] => {
156  const commands: Command[] = []
157  let words: Word[] = []
158  let writes: Word[] = []
159  let pending = undefined as 'write' | 'skip' | undefined
160
161  const endCommand = (): void => {
162    if (words.length > 0 || writes.length > 0) commands.push({ words, writes })
163    words = []
164    writes = []
165    pending = undefined
166  }
167
168  for (const [pos, token] of tokens.entries()) {
169    if (token.kind === 'sep') {
170      endCommand()
171    } else if (token.kind === 'redirect') {
172      pending = token.mode
173    } else {
174      if (pending === undefined) words.push({ value: token.value, pos })
175      else if (pending === 'write') writes.push({ value: token.value, pos })
176      pending = undefined
177    }
178  }
179  endCommand()
180
181  return commands
182}
183
184/** Überspringt Variablenzuweisungen und ein führendes sudo samt Optionen. */
185const skipPrefix = (words: readonly Word[]): readonly Word[] => {
186  let index = 0
187  let afterSudo = false
188
189  while (index < words.length) {
190    const value = words[index]?.value ?? ''
191    const isPrefix = ASSIGNMENT.test(value) || value === 'sudo' || (afterSudo && isOption(value))
192    if (!isPrefix) break
193    afterSudo = afterSudo || value === 'sudo'
194    index += 1
195  }
196
197  return words.slice(index)
198}
199
200const positionalOf = (args: readonly Word[]): Word[] => args.filter(word => !isOption(word.value))
201
202/** Das letzte von mindestens zwei Argumenten, sonst nichts. */
203const lastOf = (words: readonly Word[]): Word[] => {
204  const last = words[words.length - 1]
205
206  return words.length >= 2 && last !== undefined ? [last] : []
207}
208
209/**
210 * Zielt sed und perl mit In-place-Schalter. Ohne -e/-f ist das erste Argument das Skript,
211 * mit -e/-f sind alle Argumente Dateien. Das leere Suffix von macOS (`-i ''`) wird übersprungen.
212 */
213const inPlaceTargets = (args: readonly Word[], inPlace: RegExp): Word[] => {
214  const positional: Word[] = []
215  let enabled = false
216  let hasScript = false
217  let index = 0
218
219  while (index < args.length) {
220    const word = args[index]
221    const value = word?.value ?? ''
222    index += 1
223
224    if (SCRIPT_FLAGS.has(value)) {
225      hasScript = true
226      index += 1
227    } else if (inPlace.test(value) || value.startsWith('--in-place')) {
228      enabled = true
229      if (value === '-i' && args[index]?.value === '') index += 1
230    } else if (word !== undefined && !isOption(value)) {
231      positional.push(word)
232    }
233  }
234
235  if (!enabled) return []
236
237  return hasScript ? positional : positional.slice(1)
238}
239
240/** Die Ziele, die ein einzelner Befehl über seinen Namen und seine Argumente verändert. */
241const toolWrites = (words: readonly Word[]): Word[] => {
242  const [head, ...args] = skipPrefix(words)
243  if (head === undefined) return []
244
245  switch (basenameOf(head.value)) {
246    case 'tee':
247    case 'rm':
248    case 'touch':
249    case 'mv':
250      return positionalOf(args)
251    case 'cp':
252    case 'install':
253      return lastOf(positionalOf(args))
254    case 'sed':
255      return inPlaceTargets(args, SED_IN_PLACE)
256    case 'perl':
257      return inPlaceTargets(args, PERL_IN_PLACE)
258    default:
259      return []
260  }
261}
262
263const isNonFileTarget = (value: string): boolean => value === '' || value.startsWith(DEVICE_PREFIX)
264
265/** Alle Ziele eines Befehls in Textreihenfolge, ohne Gerätedateien. */
266const writesOf = (command: Command): Word[] =>
267  [...toolWrites(command.words), ...command.writes]
268    .filter(word => !isNonFileTarget(word.value))
269    .sort((a, b) => a.pos - b.pos)
270
271/** Die Pfade, die der Bash-Befehl schreibt, anlegt, verschiebt oder löscht. Ohne Duplikate, in Reihenfolge des Auftretens. */
272export const bashModifiedPaths = (command: string): string[] => {
273  const commands = toCommands(tokenize(stripHeredocBodies(command)))
274  const paths = commands.flatMap(writesOf).map(word => word.value)
275
276  return [...new Set(paths)]
277}
278