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

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.
| Pages | Home · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates |
| Posts | 135, in 10 series across two catalogs, every one bilingual Thai/English |
| Files | 147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded) |
| Stack | Plain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency |
| Hosting | GitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare |
| Build step | None |
| Tests | Four scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite |
| Page | What it holds |
|---|---|
index.html | Single-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.
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
| Series | Posts | Navigation |
|---|---|---|
| ⧉ AI-Core Functional Workflows | 12 | generated12-chip strip · GitHub code, tests and exercises |
| 🛡️ Engineering AI-Core Systems | 10 | chip strip · generated |
| 💻 Hermes Desktop Hands-On | 7 | chip strip · generated |
| 🧭 AI Transformation for Organizations | 20 | grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated |
| ⚡ Hermes Agent in Practice | 10 | chip strip |
| 🤖 OpenClaw for Organizations | 13 | seven are a numbered chip-strip series; six stand alone with no nav |
| 🔧 DevOps & Vibe Coding | 24 | a single prev/next chain |
Thoughts — /thoughts/, hero reads 3 Series · 39 Essays
| Series | Essays | Navigation |
|---|---|---|
| 🌱 Wisdom for a Good Life | 20 | grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated |
| 🧭 Working Philosophies | 10 | chip strip, one working week Monday to Friday · generated |
| 🌅 Life Thought & Philosophy | 9 | chip 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.
.
├── 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)
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.
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.serverdoes not and returns 404. That is not a regression.
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.
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=$RANDOMto 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.
Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:
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.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).
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.
Four skills in .claude/skills/ carry the verified detail:
| Skill | Covers |
|---|---|
| page-design | The house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by |
| blog-post | Adding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief |
| a11y-perf | Accessibility and performance rules with this site's real measured numbers |
| site-check | The 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.
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.
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.
hooks/register.tsx 477 lines1import 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}
477types/index.d.ts 25 lines1export 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