When you correct Claude ("no", "don't", "I wish", "remember: ..."), a band offers to save it as a rule in CLAUDE.md or a skill. Local phrase matching, no model…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.tsx 99 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending } from '../types'
5import { ruleFrom, SKILL_HEADER, withoutImports, withRule } from './rules'
6
7// When a typed prompt reads like a correction, a band offers to keep it as a
8// rule: in ~/.claude/CLAUDE.md, the project's CLAUDE.md, or a learned-rules
9// skill. Matching is local (no model calls); a save adds one line and keeps
10// the rest of the file as it was. No network, no processes.
11
12const pending = atom({ plugin: 'reflect-lite', key: 'pending' } as const, [])
13const HUMAN = new Set(['composer', 'bridge'])
14const MAX_PENDING = 5
15
16type Target = 'global' | 'project' | 'skill'
17
18async function home($: EngineInterface): Promise<string> {
19 const h = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
20 if (!h) throw new Error('no home directory')
21 return h.replace(/\\/g, '/').replace(/\/+$/, '')
22}
23
24async function pathFor($: EngineInterface, target: Target): Promise<string> {
25 if (target === 'project') return `${(await $.session.root()).replace(/\\/g, '/').replace(/\/+$/, '')}/CLAUDE.md`
26 if (target === 'skill') return `${await home($)}/.claude/skills/learned-rules/SKILL.md`
27 return `${await home($)}/.claude/CLAUDE.md`
28}
29
30async function save($: EngineInterface, p: Pending, target: Target) {
31 const path = await pathFor($, target)
32 const before = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
33 const after = withRule(before, withoutImports(p.rule), target === 'skill' ? SKILL_HEADER : '')
34 if (after === undefined) $.ui.toast(`reflect-lite: already in ${path}`)
35 else {
36 await $.fs.write(path, after)
37 $.ui.toast(`reflect-lite: saved to ${path}`)
38 }
39 await update($, pending, list => list.filter(x => x.id !== p.id))
40}
41
42export const register: Register = on => {
43 on('prompt.submit', async ($, e, next) => {
44 const rule = HUMAN.has(e.origin.kind) ? ruleFrom(e.text) : undefined
45 if (rule !== undefined) {
46 await update($, pending, list =>
47 list.some(x => x.rule === rule) ? list : [...list, { id: Math.max(0, ...list.map(x => x.id)) + 1, rule, isEditing: false }].slice(-MAX_PENDING),
48 ).catch(() => undefined)
49 }
50 return next(e)
51 }).catch(($, e, next) => next(e)) // never hold up a prompt
52
53 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
54 const below = await next(e)
55 const list = await read($, pending)
56 const p = list[0]
57 if (e.props.hasSurvey || p === undefined) return below
58 const ui = $.ui.resolve(e)
59 const { Box, Button, Text } = ui
60 const Input = 'Input' in ui ? ui.Input : undefined // mobile has none
61 const fail = (err: unknown) => $.ui.toast(`reflect-lite: could not save: ${err instanceof Error ? err.message : String(err)}`)
62 const press = (target: Target) => () => void save($, p, target).catch(fail)
63 const edited = (value: string) => {
64 const rule = withoutImports(value.replace(/\s+/g, ' ').trim())
65 void update($, pending, all => all.map(x => (x.id === p.id ? { ...x, rule: rule || x.rule, isEditing: false } : x)))
66 }
67 const mine = (
68 <Box flexDirection="column" width={e.props.bodyColumns}>
69 <Box flexDirection="row">
70 <Text color="#FFB000" bold>
71 [LEARN]{' '}
72 </Text>
73 <Text wrap="wrap">
74 Save as a rule? <Text color="white">"{p.rule}"</Text>
75 {list.length > 1 ? <Text color="gray"> (+{list.length - 1} more)</Text> : ''}
76 </Text>
77 </Box>
78 {p.isEditing && Input ? (
79 <Input key={`edit-${p.id}`} label="Rule" value={p.rule} submitLabel="Done" autoFocus onSubmit={edited} />
80 ) : (
81 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
82 <Button key="global" label="Global CLAUDE.md" variant="primary" onPress={press('global')} />
83 <Button key="project" label="Project CLAUDE.md" onPress={press('project')} />
84 <Button key="skill" label="Skill" onPress={press('skill')} />
85 {Input ? <Button key="edit" label="Edit" onPress={() => void update($, pending, all => all.map(x => (x.id === p.id ? { ...x, isEditing: true } : x)))} /> : ''}
86 <Button key="skip" label="Skip" onPress={() => void update($, pending, all => all.filter(x => x.id !== p.id))} />
87 </Box>
88 )}
89 </Box>
90 )
91 return (
92 <Box flexDirection="column">
93 {mine}
94 {below}
95 </Box>
96 )
97 })
98}
99hooks/rules.ts 81 lines1// Pure text rules: what counts as a correction, and how a rule is added to a file.
2
3const CORRECTION =
4 /^\s*(no\b|nope\b|don'?t\b|do not\b|stop\b|never\b|always\b|i wish\b|i'?d rather\b|i would rather\b|instead\b|that'?s (wrong|not)\b|wrong\b|not like that\b|please don'?t\b|you should(n'?t)?\b|remember:)/i
5// Text that looks like it carries a secret; a prompt with any is never offered.
6// The prompt may reach this before a redacting mod has scrubbed it, so it is
7// checked here, locally, whatever runs after.
8const SECRET = [
9 /sk-[A-Za-z0-9_-]{16,}/, // API keys of the sk- form
10 /\b[rsp]k_(live|test)_[A-Za-z0-9]{8,}/, // Stripe
11 /AKIA[0-9A-Z]{16}/, // AWS access key id
12 /AIza[0-9A-Za-z_-]{20,}/, // Google API key
13 /gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}/, // GitHub, GitLab
14 /xox[abprs]-/, // Slack
15 /-----BEGIN [A-Z ]*PRIVATE KEY/,
16 /eyJ[A-Za-z0-9_-]{20,}\./, // a JWT
17 /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i, // scheme://user:password@host
18 /\b(password|passwd|pwd|passphrase|secret|token|api[_-]?key|access[_-]?key)\s*[=:]\s*\S{4,}/i,
19 /\[REDACTED\b/i, // a redacting mod's placeholder: the prompt held a secret
20 /(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,}/, // a long token of letters and digits
21]
22const MAX_PROMPT = 300
23const MAX_RULE = 200
24
25export const looksSecret = (text: string) => SECRET.some(re => re.test(text))
26
27/**
28 * The rule with each word that holds an @ in a code span: CLAUDE.md reads
29 * `@path` as an import that loads that file into every session, and never
30 * inside a code span.
31 */
32export function withoutImports(rule: string): string {
33 return rule.replace(/\S*@\S*/g, word => {
34 const [, lead = '', core = word, tail = ''] = /^([("'[]*)(.*?)([)"'\].,;:!?]*)$/.exec(word) ?? []
35 if (/^`[^`]+`$/.test(core)) return word
36 return `${lead}\`${core.replace(/`/g, '')}\`${tail}`
37 })
38}
39
40/** The rule a prompt offers, or undefined when it is not a correction worth keeping. */
41export function ruleFrom(text: string): string | undefined {
42 const t = text.trim()
43 if (!t || t.startsWith('/') || t.length > MAX_PROMPT || looksSecret(t) || !CORRECTION.test(t)) return undefined
44 const remembered = /^\s*remember:\s*(.+)$/is.exec(t)
45 const rule = (remembered?.[1] ?? t).replace(/\s+/g, ' ').trim()
46 if (rule.length < 4) return undefined
47 return withoutImports(rule.length > MAX_RULE ? rule.slice(0, MAX_RULE - 1) + '…' : rule)
48}
49
50export const HEADING = '## Learned rules'
51
52export const SKILL_HEADER = `---
53name: learned-rules
54description: Rules the user taught Claude by correcting it. Follow them in all work in every project.
55---
56`
57
58/**
59 * The file with `- rule` added under the Learned rules heading, made if
60 * missing; everything else in the file is kept as it was. Undefined when the
61 * rule is already there.
62 */
63export function withRule(file: string, rule: string, header = ''): string | undefined {
64 const nl = file.includes('\r\n') ? '\r\n' : '\n'
65 const bullet = `- ${rule}`
66 if (file.split(/\r?\n/).some(line => line.trim() === bullet)) return undefined
67 let text = file === '' ? header.replace(/\n/g, nl) : file
68 const at = text.indexOf(HEADING)
69 if (at === -1) {
70 const sep = text === '' ? '' : text.endsWith(nl + nl) ? '' : text.endsWith(nl) ? nl : nl + nl
71 return `${text}${sep}${HEADING}${nl}${nl}${bullet}${nl}`
72 }
73 // insert at the end of that section: before the next heading of level 1-2, or at the end
74 const after = at + HEADING.length
75 const rest = text.slice(after)
76 const next = rest.search(/\r?\n#{1,2} /)
77 const end = next === -1 ? text.length : after + next
78 const body = text.slice(0, end).replace(/(\r?\n)*$/, '')
79 return `${body}${nl}${bullet}${nl}${text.slice(end).replace(/^(\r?\n)*/, next === -1 ? '' : nl)}`
80}
81types/index.d.ts 8 lines1export type Pending = { id: number; rule: string; isEditing: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'reflect-lite': { pending: Pending[] }
6 }
7}
8