SLOPSHOPPER

reflect-lite

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…

newbandtoastprompt
v0.2.0MITupdated 2026-10-15aecheman27-gif/claude-mods/reflect-lite
A shopper browsing a rack in a slop shop
README

claude-mods

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.

Source 3 files
hooks/register.tsx 99 lines
1import { 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}
99
hooks/rules.ts 81 lines
1// 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}
81
types/index.d.ts 8 lines
1export type Pending = { id: number; rule: string; isEditing: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'reflect-lite': { pending: Pending[] }
6  }
7}
8