SLOPSHOPPER

reflect

When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or in a rules folder you choose

newbandcommandpromptmodelprocess
v0.1.0no licenseupdated 2026-10-26Anirach/Anirach.github.io/.claude/skills/reflect
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reflect
› 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 › /reflect ⎿ reflect: Reflect is off. Type /reflect to turn it back on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

anirach.com

Current publication inventory — 2026-10-24: 135 bilingual posts across10 series (7 Tutorials /96 articles,3 Thoughts /39 essays),147 HTML files (146 enumerated), 466 images,135 feed items and146 sitemap URLs. AI-Core Functional Workflows adds twelve lessons paired with Anirach/ai-core-functional-tutorial,108 offline tests and twelve exercises. Earlier dated measurements below retain their historical context.

The personal site of Dr. Anirach Mingkhwan — Associate Professor at King Mongkut's University of Technology North Bangkok (KMUTNB), PhD (Liverpool John Moores University, UK), researcher in Agentic AI, computer networks, and big data analytics.

🌐 Live: anirach.com

A hand-written static site — seven top-level pages, four per-book detail pages, and 135 self-contained blog posts catalogued on two pages: Tutorials (/blog/, seven technical series) and Thoughts (/thoughts/, thirty-nine essays on living well and working well). No build system, no package manager, no dependencies, no JavaScript framework. Push to main and GitHub Pages publishes it.


At a glance

PagesHome · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates
Posts135, in 10 series across two catalogs, every one bilingual Thai/English
Files147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded)
StackPlain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency
HostingGitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare
Build stepNone
TestsFour scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite

The seven pages

PageWhat it holds
index.htmlSingle-scroll consultant profile: hero, at-a-glance stats, six services, engagement approach, selected work, about and career, research, free resources, latest news with the featured book, contact
blog/The Tutorials catalog — 96 cards in seven technical series, plus all 135 post files
thoughts/The Thoughts catalog — 39 essay cards in three series, each linking ../blog/<slug>.html
publications/The academic record: the Springer book Libraries in Transformation, 8 book chapters, and a selected-publications table
books/Books & writing: four works — One Day of Light (the free last-lecture event book, EN/TH editions with free PDF downloads served from books/), the published novel Three Old Men: The Last Conversation, and two complete bilingual manuscripts — each with its own detail page
projects/Live apps (each verified working before it ships) and research-code repositories
news/Reverse-chronological timeline of publications, talks and appointments, plus a career timeline

The posts never move. Every post is blog/<slug>.html, including the 39 essays that /thoughts/ catalogues — the 2026-09-08 split separated the two catalogs, not the files.

books/ was one "Books & Writing" page until 2026-08-23, when it split: academic content moved to the new publications/, and books/ became the fiction section with one detail page per book. On 2026-08-24 it gained a fourth work, the Last Lecture companion book One Day of Light, whose EN/TH PDFs are downloadable for free from books/.

script.js is gone — deleted 2026-08-26. The site loads no executable JavaScript at all, and check_site.py INV-38 fails the build on any <script> that is not application/ld+json (schema.org metadata, which the browser parses as data and never executes — 128 pages carry one). Each of the script's four jobs has a CSS replacement in style.css: the scroll-reveal and the nav scroll state are scroll-driven CSS animations (animation-timeline: view() / scroll(root)), the landing page's mobile menu is .nav__links:target, and anchor scrolling is native scroll-behavior: smooth. The other pages' mobile menu is a pure-CSS checkbox toggle — a second mechanism, also without JavaScript.

The two catalogs

Both are static, zero-JavaScript and generated by scripts/reindex_blog.py — re-run it rather than hand-ordering cards. Each is a hero, a sticky chip strip, a "Start here" feature, a row of series tiles, then one numbered-row-card section per series. There is no filtering, sorting or search UI.

Tutorials — /blog/, hero reads 7 Series · 96 Articles

SeriesPostsNavigation
⧉ AI-Core Functional Workflows12generated12-chip strip · GitHub code, tests and exercises
🛡️ Engineering AI-Core Systems10chip strip · generated
💻 Hermes Desktop Hands-On7chip strip · generated
🧭 AI Transformation for Organizations20grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated
⚡ Hermes Agent in Practice10chip strip
🤖 OpenClaw for Organizations13seven are a numbered chip-strip series; six stand alone with no nav
🔧 DevOps & Vibe Coding24a single prev/next chain

Thoughts — /thoughts/, hero reads 3 Series · 39 Essays

SeriesEssaysNavigation
🌱 Wisdom for a Good Life20grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated
🧭 Working Philosophies10chip strip, one working week Monday to Friday · generated
🌅 Life Thought & Philosophy9chip strip, one day in three parts (Morning · Noon · Twilight)

Five of the nine series are generated from a manifest plus per-post content sheets: python3 scripts/build_series.py --series <name>, with manifests in scripts/series/ (ai-core, ai-transformation, good-life, hermes-desktop, working). For those, the manifest and the sheets are the edit surface — never the emitted post.

Every post is bilingual. All 123 carry a pure-CSS Thai ⇄ English switch: a ไทย · English pill in the hero swaps between two complete tracks of the same article. Thai is the default, there is no JavaScript, and the page-level <html lang="th"> never changes — the English track is lang="en" inside it. The seven top-level pages and the four book detail pages are lang="en" chrome and wrap their Thai passages in <span lang="th">.

The Thoughts essays are written in a different register from the tutorials — literary Thai in the author's own voice, not code-switched technical prose. The brief, with ten rules and worked before/after pairs, is .claude/skills/blog-post/references/thai-essay-voice.md.

Repository structure

.
├── index.html          # Landing page — the ONLY file that loads style.css
├── style.css           # Landing-page styles; carries the canonical :root token block
├── _config.yml         # Jekyll: keeps internal working docs out of the published site
├── CNAME               # anirach.com
├── sitemap.xml         # 134 URLs, generated · feed.xml — 123 items, generated · llms.txt · robots.txt
├── blog/               # index.html (the Tutorials catalog, 96 cards) + all 123 self-contained posts
├── thoughts/           # index.html (the Thoughts catalog — 39 essay cards linking ../blog/)
├── books/              # index.html + 4 per-book detail pages + 3 free PDFs, all self-contained
├── publications/  projects/  news/   # one self-contained index.html each
├── images/             # 431 files — 124 covers (800×800) + 8 book faces, 123 card thumbs (144×144),
│                       #   130 share cards (1200×630), diagrams, series figures, posters, profile photo
├── scripts/            # 13 generators and sweeps (build_series, reindex_blog, gen_feed, gen_sitemap,
│                       #   make_cover, make_figure, bilingualize, check_visibility, …)
├── docs/               # design spec, implementation plan, OpenClaw runbook (not published)
└── .claude/skills/     # the four maintenance skills (not published)

Architecture: every page is an island

The single most important fact before editing:

index.html ──▶ style.css                 (the ONLY consumer of it)

blog/index.html   ──▶ its own <style> block, no JS
blog/<post>.html  ──▶ its own <style> block, no JS     × 123
thoughts/ books/ (index + 4 detail) publications/ projects/ news/ 404.html ──▶ same   × 10

Every page embeds its complete stylesheet. There is no shared partial, template engine or token file, so a "global" change means editing N files by hand — and nothing tells you when file 23 of 135 got missed. That is what the linter is for. The compensating upside: blast radius is exactly one file.

Local development

No install, no build. Serve the repository root:

python3 -m http.server 8000     # → http://localhost:8000

Serve the root, not blog/. Every blog page references images as ../images/….

Known local-dev trap. The eight chip-strip series — 93 posts in all — link to each other with root-absolute, extensionless URLs (/blog/openclaw-101). GitHub Pages resolves those; http.server does not and returns 404. That is not a regression.

Verification

Run all four from the repo root before pushing. All must pass:

python3 .claude/skills/site-check/scripts/check_site.py      # 62 cross-file integrity checks
python3 scripts/check_visibility.py --strict                 # search/AI-visibility, JSON-LD, metadata
python3 docs/openclaw/check-news-sync.py                     # news↔homepage sync, provenance, 4 counters
python3 .claude/skills/blog-post/assets/verify-wiring.py     # blog post wiring

check_site.py runs 62 checks — 43 fail-severity, 17 warn, 2 info — and exits 0 when no fail-severity violation appears. Its baseline of pre-existing debt is empty since 2026-08-26 — the tree is clean (0 new, 0 known), so any violation it prints is new and any fail-severity one blocks the push. INV-25 fails the build if a baseline entry is ever added and goes stale, so the safety net cannot silently rot. INV-26 (added with the books split) ties every section-directory detail page to its own index.html: an orphan detail page, or an index card linking a file that does not exist, fails the build.

check-news-sync.py verifies the homepage strip mirrors the three newest news items, that every news item carries a <!-- source: … --> provenance comment, and that all four hand-typed counters match reality: news/index.html "7 updates", publications/index.html "8 chapters", and books/index.html "1 novel" and "2 complete" (it strips HTML comments first, so a commented-out card cannot satisfy a counter).

feed.xml and sitemap.xml are generated, not hand-written — scripts/gen_feed.py and scripts/gen_sitemap.py. INV-31 and INV-32 fail the build when either drifts from the catalogs. Run the sitemap in the commit after a content change, so the git lastmod dates exist.

Deployment

git push origin main

That is the whole pipeline; GitHub Pages rebuilds in 1–3 minutes. _config.yml excludes docs/ and CLAUDE.md from the published output — they stay in the repository but are not served. Anything beginning with . or _ (including .claude/) is omitted by Jekyll automatically.

Cloudflare caches HTML for 10 minutes and assets for 4 hours. After a deploy, append ?cb=$RANDOM to check the real state before diagnosing a problem that is not there — and remember your own browser holds its copy for the same 10 minutes, so a stale tab needs a hard reload rather than a bug report.

Performance notes

Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:

  • **Fonts are requested with the variable range syntax. Inter:wght@300..900 gets one variable file; the semicolon form Inter:wght@300;400;…;900 makes the API serve one static file per weight — 7 files and 331 KB for the latin subset against 1 file and 47 KB. Switching saved 189 KB per page, 56% of the font bytes. Sarabun keeps the semicolon form**: it has no variable version, a range request returns HTTP 400, and all five of its static weights are used.
  • Catalog cards ship a 144px derivative through srcset. The row card renders its cover in a 72px box, so an 800×800 source was ~11× oversampled. /blog/ went from 3.63 MB to 0.42 MB, /thoughts/ from 2.11 MB to 0.38 MB.

Both are documented with their measurements in the a11y-perf skill (R8b and R1b).

Adding a new blog post

A post is not one file — it is a file plus a cover plus a share card plus a card plus counters plus its neighbours' nav links plus a feed item, a sitemap entry and an llms.txt line, in two language tracks, with no build step to catch a miss. The full recipe, including which of the four navigation patterns applies, is in .claude/skills/blog-post/SKILL.md. Start from .claude/skills/page-design/assets/post-template.html — the repo's single canonical template. For one of the five generated series, add a manifest row and run build_series.py instead; never hand-edit those posts.

Maintenance

Four skills in .claude/skills/ carry the verified detail:

SkillCovers
page-designThe house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by
blog-postAdding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief
a11y-perfAccessibility and performance rules with this site's real measured numbers
site-checkThe linter, what every check means, and how to repair each failure

Every number in those skills is load-bearing: a change that invalidates one must update it in the same commit. A confidently wrong measurement is worse than none, because it gets acted on.

News updates have their own runbook at docs/openclaw/latest-updates-runbook.md, written for an autonomous agent. Its gate enforces that every news item carries a verified source — no structural check can tell a true item from an invented one, so instead an item cannot exist without a stated source.

Contact

Open to research collaborations, speaking engagements, and academic partnerships.


No LICENSE file is present. Content and code are © Anirach Mingkhwan; all rights reserved by default.

AI-Core Functional Workflows

Twelve bilingual lessons and the matching code are maintained together: Anirach/ai-core-functional-tutorial. The offline lab ZIP is downloads/ai-core-functional-lab.zip. The series manifest is scripts/series/ai-core-functional.json; article source links pin the reviewed companion commit. Default fixtures require no SDK install, API key or paid calls. Run the companion tests/advertised commands and the four website checks when revising. No additional reuse license is granted by the companion's public availability.

Source 2 files
hooks/register.tsx 477 lines
1import type { EngineInterface, Register, SessionMessage } from 'claude-code'
2
3import type { Band } from '../types'
4
5const BAND = { plugin: 'reflect', key: 'band' } as const
6
7const DEFAULT_FOLDER = '~/Documents/rules'
8const CANCEL = 'Cancel'
9const HEADING = '## Rules from corrections'
10const SAVED_MS = 8000
11
12const ACCENT = '#d97757'
13const GOOD = '#16a34a'
14const BAD = '#dc2626'
15
16// The cheap first pass: only prompts with one of these go on to the model.
17const HINTS = [
18  /\bno\b/i,
19  /\bdon'?t\b/i,
20  /\bdo not\b/i,
21  /\bnever\b/i,
22  /\bstop\b/i,
23  /\binstead\b/i,
24  /\bi said\b/i,
25  /\bwrong\b/i,
26  /\btoo (long|short|much|many|wordy|formal|casual)\b/i,
27  /\bshorter\b/i,
28  /\bnot like that\b/i,
29  /\balways\b/i,
30  /\buse\b.{1,40}\bnot\b/i,
31  /\bremove\b/i,
32  /\bavoid\b/i,
33]
34
35// Module variables reset on a reload; the band lives in $.state.
36let home = ''
37// The rules_folder option from the config menu; empty means ask once and remember.
38let configured = ''
39let enabled = true
40// Bumped on every prompt, so a slow draft for an older prompt is dropped.
41let gen = 0
42let turnDone = true
43let pending: { rule: string; slug: string; correction: string } | null = null
44let nextId = 1
45
46// The /mods switchboard turns this mod off in every chat with switches.json.
47const NAME = 'reflect'
48let off = false
49
50async function readSwitch($: EngineInterface) {
51  let now = false
52  try {
53    const path = `${$.plugin.root.split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
54    if (await $.fs.exists(path)) {
55      const json = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
56      now = json[NAME] === false
57    }
58  } catch {
59    now = off
60  }
61  if (now === off) return
62  off = now
63  try {
64    $.ui.invalidate('ui.render')
65  } catch {
66    // The next state change redraws anyway.
67  }
68  if (off) pending = null
69}
70
71const tilde = (p: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p)
72
73async function getBand($: EngineInterface): Promise<Band | null> {
74  const { value } = await $.state.get(BAND)
75  return value ?? null
76}
77
78async function setBand($: EngineInterface, b: Band | null) {
79  await $.state.set(BAND, b)
80}
81
82async function log($: EngineInterface, what: string, err: unknown) {
83  try {
84    const path = `${home}/.claude/mods-logs/reflect.log`
85    const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
86    const msg = err instanceof Error ? err.message : String(err)
87    await $.fs.write(path, `${old}${new Date(await $.clock.now()).toISOString()} ${what}: ${msg}\n`)
88  } catch {
89    // Nowhere left to report it.
90  }
91}
92
93async function today($: EngineInterface) {
94  const d = new Date(await $.clock.now())
95  const pad = (n: number) => String(n).padStart(2, '0')
96  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
97}
98
99function looksLikeCorrection(text: string) {
100  const t = text.trim()
101  if (!t || t.startsWith('/') || t.length > 1500) return false
102  return HINTS.some(re => re.test(t))
103}
104
105// Plain text rules: no em or en dashes, never the banned word, one line, a full stop.
106function cleanRule(raw: string) {
107  let r = raw
108    .replace(/\s*[—–]\s*/g, ', ')
109    .replace(/\bship(s|ped|ping)?\b/gi, (_m, end: string | undefined) => {
110      const e = (end ?? '').toLowerCase()
111      return e === 's' ? 'publishes' : e === 'ped' ? 'published' : e === 'ping' ? 'publishing' : 'publish'
112    })
113    .replace(/\s+/g, ' ')
114    .trim()
115    .replace(/^[-*"'`\s]+|["'`\s]+$/g, '')
116  if (!r) return ''
117  r = r[0]!.toUpperCase() + r.slice(1)
118  if (!/[.!?]$/.test(r)) r += '.'
119  return r
120}
121
122const STOP = new Set(['a', 'an', 'the', 'in', 'on', 'of', 'to', 'for', 'and', 'or', 'with', 'when', 'any', 'all', 'my', 'your'])
123
124function cleanSlug(raw: string, rule: string) {
125  const words = (s: string) =>
126    s
127      .toLowerCase()
128      .replace(/[^a-z0-9]+/g, '-')
129      .split('-')
130      .filter(Boolean)
131  let w = words(raw)
132  if (w.length < 2) w = words(rule).filter(x => !STOP.has(x))
133  return w.slice(0, 6).join('-').slice(0, 60) || 'rule'
134}
135
136function draftPrompt(correction: string, lastReply: string) {
137  return `A person corrected an AI assistant. Turn the correction into ONE general rule the assistant should follow in every future chat.
138
139The assistant's last reply (first 1,500 characters):
140<reply>
141${lastReply}
142</reply>
143
144The person's correction:
145<correction>
146${correction}
147</correction>
148
149Rules for the rule:
150- One line, plain imperative English, under 20 words. Example: Never use em dashes in newsletter copy.
151- General enough to apply next time, specific enough to act on. Name the kind of work it applies to when that matters.
152- No em dashes. Never use the word "ship".
153
154Also give a 3 to 6 word kebab-case slug for it, for example no-em-dashes-in-newsletters.
155
156Reply with JSON only: {"rule": "...", "slug": "..."}
157If the message holds no lasting lesson (a one-off fix to this task only), reply {"rule": "NONE", "slug": ""}.`
158}
159
160function parseDraft(text: string): { rule: string; slug: string } | null {
161  const m = /\{[\s\S]*\}/.exec(text)
162  let rule = ''
163  let slug = ''
164  if (m) {
165    try {
166      const j = JSON.parse(m[0]) as { rule?: unknown; slug?: unknown }
167      rule = typeof j.rule === 'string' ? j.rule : ''
168      slug = typeof j.slug === 'string' ? j.slug : ''
169    } catch {
170      // Fall through to the first line.
171    }
172  }
173  if (!rule) rule = text.split('\n').find(l => l.trim()) ?? ''
174  if (!rule || /^none\.?$/i.test(rule.trim())) return null
175  const clean = cleanRule(rule)
176  if (!clean || /^none\.?$/i.test(clean)) return null
177  return { rule: clean, slug: cleanSlug(slug, clean) }
178}
179
180function lastAssistantText(messages: readonly SessionMessage[]) {
181  for (let i = messages.length - 1; i >= 0; i -= 1) {
182    const m = messages[i]!
183    if (m.role === 'assistant' && m.text.trim()) return m.text
184  }
185  return ''
186}
187
188async function show($: EngineInterface, d: { rule: string; slug: string; correction: string }) {
189  if (!enabled) return
190  const id = nextId++
191  await setBand($, { phase: 'offer', id, rule: d.rule, slug: d.slug, correction: d.correction, path: '', shortPath: '', note: '' })
192}
193
194// Runs in the background after a prompt: filter, classify, draft.
195async function check($: EngineInterface, text: string, mine: number) {
196  if (!looksLikeCorrection(text)) return
197  const messages = await $.session.messages()
198  const lastReply = lastAssistantText(messages)
199  if (!lastReply) return
200  const kind = await $.model.classify(text, ['correction', 'other'])
201  if (kind !== 'correction' || mine !== gen) return
202  const r = await $.model.complete({ model: 'sonnet', prompt: draftPrompt(text, lastReply.slice(0, 1500)), maxTokens: 300, timeoutMs: 30_000 })
203  if (!r.isAnswered) throw new Error(`draft failed: ${r.reason}`)
204  const draft = parseDraft(r.text)
205  if (!draft || mine !== gen) return
206  const d = { ...draft, correction: text.trim() }
207  // Show it when the turn ends; if it already has, show it now.
208  if (turnDone) await show($, d)
209  else pending = d
210}
211
212// Adds the rule under the heading at the end of the file, keeping every other line.
213function withRule(old: string, line: string) {
214  const lines = old.split('\n')
215  const at = lines.findIndex(l => l.trim() === HEADING)
216  if (at === -1) {
217    const base = old.replace(/\s+$/, '')
218    return `${base}${base ? '\n\n' : ''}${HEADING}\n\n${line}\n`
219  }
220  let end = lines.length
221  for (let k = at + 1; k < lines.length; k += 1) {
222    if (/^#{1,2}\s/.test(lines[k]!)) {
223      end = k
224      break
225    }
226  }
227  let last = end
228  while (last > at + 1 && !lines[last - 1]!.trim()) last -= 1
229  const before = lines.slice(0, last)
230  if (last === at + 1) before.push('')
231  const after = lines.slice(end)
232  const out = [...before, line, ...(after.length ? ['', ...after] : [])].join('\n')
233  return out.endsWith('\n') ? out : `${out}\n`
234}
235
236async function saveEverywhere($: EngineInterface, b: Band) {
237  const path = `${home}/.claude/CLAUDE.md`
238  const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
239  const already = old.split('\n').some(l => l.startsWith(`- ${b.rule} (added `))
240  if (already) return { path, note: 'Already saved' }
241  const line = `- ${b.rule} (added ${await today($)})`
242  const next = withRule(old, line)
243  const oldLines = old.split('\n').filter(l => l.trim())
244  const nextLines = new Set(next.split('\n'))
245  if (next.length < old.length || oldLines.some(l => !nextLines.has(l))) throw new Error('the new CLAUDE.md would lose lines; nothing written')
246  await $.fs.write(path, next)
247  const back = String(await $.fs.read(path))
248  if (back !== next) throw new Error('CLAUDE.md did not read back as written')
249  return { path, note: '' }
250}
251
252// Turns ~ into the home folder and drops a trailing slash.
253function expand(p: string) {
254  let r = p.trim()
255  if (r === '~') r = home
256  else if (r.startsWith('~/')) r = `${home}${r.slice(1)}`
257  return r.replace(/\/+$/, '')
258}
259
260// The folder rule notes go to: the rules_folder option, else the remembered answer, else ask once.
261async function pickFolder($: EngineInterface) {
262  if (configured.trim()) return expand(configured)
263  const saved = await $.store.get('folder')
264  if (typeof saved === 'string' && saved.trim()) return expand(saved)
265  let answer = ''
266  try {
267    // The dialog needs two options; any other path is typed under Other.
268    answer = (await $.ui.ask('Which folder should rule notes go in?', { options: [DEFAULT_FOLDER, CANCEL], header: 'Rules folder' })).trim()
269  } catch {
270    // A cancelled dialog saves nothing.
271    return ''
272  }
273  if (!answer || answer === CANCEL || (!answer.startsWith('/') && !answer.startsWith('~'))) return ''
274  const dir = expand(answer)
275  if (!dir) return ''
276  await $.store.set('folder', answer)
277  return dir
278}
279
280function vaultNote(b: Band, date: string) {
281  const title = b.rule.replace(/[.!?]+$/, '')
282  const why = b.correction.replace(/\s+/g, ' ').trim().slice(0, 200).replace(/"/g, "'")
283  return `---
284type: rule
285status: active
286date: ${date}
287source: correction in a Claude Code chat
288tags: [rule]
289---
290
291# ${title}
292
293${b.rule}
294
295Why: "${why}"
296`
297}
298
299async function saveToVault($: EngineInterface, b: Band) {
300  const dir = await pickFolder($)
301  if (!dir) return null
302  // $.fs.write creates the folder and any missing parents.
303  let path = `${dir}/${b.slug}.md`
304  for (let k = 2; (await $.fs.exists(path)) && k < 100; k += 1) path = `${dir}/${b.slug}-${k}.md`
305  const text = vaultNote(b, await today($))
306  await $.fs.write(path, text)
307  const back = String(await $.fs.read(path))
308  if (back !== text) throw new Error('the rule file did not read back as written')
309  return { path, short: tilde(path) }
310}
311
312async function hideLater($: EngineInterface, id: number) {
313  await $.clock.sleep(SAVED_MS)
314  const b = await getBand($)
315  if (b && b.id === id && b.phase === 'saved') await setBand($, null)
316}
317
318async function markSaved($: EngineInterface, b: Band, path: string, shortPath: string, note: string) {
319  const id = nextId++
320  await setBand($, { ...b, phase: 'saved', id, path, shortPath, note })
321  void hideLater($, id).catch(err => log($, 'hide', err))
322}
323
324async function fail($: EngineInterface, b: Band, what: string, err: unknown) {
325  await log($, what, err)
326  await setBand($, { ...b, note: `Could not save: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}` })
327}
328
329export const register: Register = (on, options) => {
330  configured = typeof options.rules_folder === 'string' ? options.rules_folder : ''
331  on('session.start', async ($, e, next) => {
332    home = $.plugin.root.split('/.claude/')[0] ?? ''
333    await $.command.register({ name: 'reflect', description: 'Turn reflect on or off. /reflect folder forgets the remembered rules folder', argumentHint: '[folder]' })
334    const saved = await $.store.get('enabled')
335    enabled = saved !== false
336    await readSwitch($).catch(() => undefined)
337    $.clock.every(3000, () => {
338      void readSwitch($).catch(() => undefined)
339    })
340    return next(e)
341  })
342
343  on('prompt.submit', async ($, e, next) => {
344    if (off) return next(e)
345    if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
346    gen += 1
347    turnDone = false
348    pending = null
349    const mine = gen
350    const text = e.text
351    // A new prompt makes an unanswered offer stale.
352    void (async () => {
353      const b = await getBand($)
354      if (b?.phase === 'offer') await setBand($, null)
355    })().catch(err => log($, 'clear', err))
356    if (enabled) void check($, text, mine).catch(err => log($, 'check', err))
357    return next(e)
358  })
359
360  on('turn.complete', async ($, e, next) => {
361    if (off) return next(e)
362    const result = await next(e)
363    if (e.agentId === undefined) {
364      turnDone = true
365      if (pending) {
366        const d = pending
367        pending = null
368        void show($, d).catch(err => log($, 'show', err))
369      }
370    }
371    return result
372  })
373
374  on('command.run', { command: 'reflect' }, async ($, e) => {
375    if (off) return { text: 'Reflect is off. Turn it on with /mods.' }
376    const arg = e.args.trim().toLowerCase()
377    if (arg === 'folder') {
378      await $.store.delete('folder')
379      return {
380        text: configured.trim()
381          ? `Remembered folder cleared. Save to vault still uses the rules_folder setting: ${configured.trim()}`
382          : 'Remembered folder cleared. Reflect asks again at the next Save to vault.',
383      }
384    }
385    enabled = !enabled
386    await $.store.set('enabled', enabled)
387    if (!enabled) {
388      pending = null
389      await setBand($, null)
390    }
391    return { text: enabled ? 'Reflect is on. Corrections get a Save this as a rule? offer.' : 'Reflect is off. Type /reflect to turn it back on.' }
392  })
393
394  on('ui.press', { plugin: 'reflect', element: 'everywhere' }, async ($, e) => {
395    const b = await getBand($)
396    if (!b || b.phase !== 'offer') return { element: e.element }
397    try {
398      const r = await saveEverywhere($, b)
399      await markSaved($, b, r.path, tilde(r.path), r.note)
400    } catch (err) {
401      await fail($, b, 'save everywhere', err)
402    }
403    return { element: e.element }
404  })
405
406  on('ui.press', { plugin: 'reflect', element: 'vault' }, async ($, e) => {
407    const b = await getBand($)
408    if (!b || b.phase !== 'offer') return { element: e.element }
409    try {
410      const r = await saveToVault($, b)
411      if (!r) {
412        await setBand($, { ...b, note: 'No folder picked. Nothing saved.' })
413      } else {
414        await markSaved($, b, r.path, r.short, '')
415      }
416    } catch (err) {
417      await fail($, b, 'save to vault', err)
418    }
419    return { element: e.element }
420  })
421
422  on('ui.press', { plugin: 'reflect', element: 'skip' }, async ($, e) => {
423    await setBand($, null)
424    return { element: e.element }
425  })
426
427  on('ui.press', { plugin: 'reflect', element: 'open' }, async ($, e) => {
428    const b = await getBand($)
429    if (b?.path) await $.process.run(['open', b.path])
430    return { element: e.element }
431  })
432
433  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
434    if (off || !enabled || e.props.hasSurvey || e.props.isWorking) return next(e)
435    const b = await getBand($)
436    if (!b) return next(e)
437    const rest = await next(e)
438    const { Box, Text, Button } = $.ui.resolve(e)
439    const noop = () => undefined
440
441    if (b.phase === 'saved') {
442      return (
443        <Box flexDirection="column">
444          {rest}
445          <Box flexDirection="row" alignItems="center" gap={1}>
446            <Text wrap="truncate-middle">
447              <Text color={GOOD}>✓</Text>
448              <Text> {b.note ? `${b.note} in` : 'Saved to'} </Text>
449              <Text dimColor>{b.shortPath}</Text>
450            </Text>
451            <Button key="open" label="Open" onPress={noop} />
452          </Box>
453        </Box>
454      )
455    }
456
457    return (
458      <Box flexDirection="column">
459        {rest}
460        <Box flexDirection="column">
461          <Text>
462            <Text color={ACCENT}>●</Text>
463            <Text bold> Save this as a rule?</Text>
464          </Text>
465          <Text>"{b.rule}"</Text>
466          <Box flexDirection="row" gap={1}>
467            <Button key="everywhere" label="Save everywhere" variant="primary" onPress={noop} />
468            <Button key="vault" label="Save to vault" variant="secondary" onPress={noop} />
469            <Button key="skip" label="Skip" role="dismiss" onPress={noop} />
470          </Box>
471          {b.note ? <Text color={BAD}>{b.note}</Text> : null}
472        </Box>
473      </Box>
474    )
475  })
476}
477
types/index.d.ts 25 lines
1export type Band = {
2  /** offer: the rule and the three buttons. saved: one line with Open, for 8 seconds. */
3  phase: 'offer' | 'saved'
4  /** Changes with every new band, so an old timer never clears a newer one. */
5  id: number
6  /** The drafted rule, one line. */
7  rule: string
8  /** 3 to 6 words, kebab-case, for the vault file name. */
9  slug: string
10  /** The person's correction, as typed. */
11  correction: string
12  /** Where the rule was saved (saved phase). */
13  path: string
14  /** The path as shown on the band. */
15  shortPath: string
16  /** A line under the buttons: an error or "Already saved". */
17  note: string
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    reflect: { band: Band | null }
23  }
24}
25