Offers to shape a long, loose prompt into a four-part brief (the job, the why, the guardrails, done means), asking up to three questions about real gaps first

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 325 lines1import type { EngineInterface, PromptSubmitInput, Register } from 'claude-code'
2
3// A long, loose prompt (a voice-note brain dump) gets an offer: shape it into
4// the four-part brief first. Shaped text goes back in the prompt box for the
5// person to read and edit; their next Enter sends it, since it holds THE JOB.
6
7const CMD = 'shape'
8const MIN_WORDS = 120
9const MODEL = 'sonnet'
10const QUESTION = 'This looks like a brain dump. Shape it into a clear brief first?'
11const SHAPE = 'Shape it'
12const AS_IS = 'Send as is'
13const DROP_REASON = 'shaped into a brief'
14const DONE_TOAST = 'Shaped into a brief. Check it, then press Enter.'
15const HEADINGS = ['THE JOB', 'THE WHY', 'THE GUARDRAILS', 'DONE MEANS'] as const
16const MAX_QUESTIONS = 3
17const SKIPPED = 'skipped, no answer'
18const STATUS_START = 'Shaping your brain dump...'
19const STATUS_GAPS = 'Shaping your brain dump: reading for gaps'
20const STATUS_WRITE = 'Shaping your brain dump: writing the brief'
21// The dialog's chip holds 12 characters at most, so "Dump 1 of 2", not "Brain dump · 1 of 2".
22const askHeader = (i: number, n: number) => `Dump ${i} of ${n}`
23const ENABLED = { plugin: 'brain-dump', key: 'enabled' } as const
24
25const SYSTEM = `You rewrite a person's spoken brain dump into a clear brief for an AI assistant.
26
27Output exactly this shape and nothing else, no preamble, no code fences:
28
29THE JOB
30<one or two lines: what to make or do>
31
32THE WHY
33<who it is for and what it is used for>
34
35THE GUARDRAILS
36- <rules, limits, things to avoid, sources to use>
37
38DONE MEANS
39- <how we know it is finished and right>
40
41Rules:
42- Keep every fact, name, number, date, link, file and tool from the dump. Drop only filler and repeats.
43- Add no new facts, numbers, names or requirements that are not in the dump.
44- Where the dump says nothing for a part, write "unknown: ask me" for that part.
45- The prompt may carry <answers>: follow-up questions the person answered. Treat each answer as their own words and use it in the part it belongs to. Where an answer says it was skipped, write that point as "unknown: ask me" in the part it belongs to.
46- Write in the person's voice (I, we, my), as if they wrote the brief themselves.
47- Plain, short English. Short lines and bullets.
48- Never use em dashes or en dashes. Never use the word "ship"; say publish or deliver instead.`
49
50// Step A: a second, cheap read that only looks for real gaps or conflicts.
51const GAPS_SYSTEM = `You read a person's spoken brain dump before it is turned into a brief for an AI assistant.
52Find only real gaps: two instructions in the dump that contradict each other, or a must-know fact that is missing and would change the work (for example who it is for, the deadline, the format, or which source to use).
53
54Return JSON only, no prose, no code fences, in exactly this shape:
55{"questions":[{"q":"<short question ending in a question mark>","options":["<option>","<option>"]}]}
56
57Rules:
58- 0 to 3 questions. When the dump is clear, return {"questions":[]}. Fewer is better.
59- Never ask about something the dump already answers, and never ask about style or nice-to-haves.
60- For a conflict, name both sides in the question and offer each side as an option.
61- Each question has 2 or 3 short options (1 to 5 words), taken from the dump where possible. No "Other" option; the person can type their own.
62- Plain, short English. Never use em dashes or en dashes. Never use the word "ship"; say publish or deliver instead.`
63
64export type Gap = { q: string; options: string[] }
65export type QA = { q: string; a: string | null }
66
67let home = ''
68
69// The /mods switchboard turns this mod off in every chat with switches.json.
70const NAME = 'brain-dump'
71let off = false
72
73async function readSwitch($: EngineInterface) {
74 let now = false
75 try {
76 const path = `${$.plugin.root.split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
77 if (await $.fs.exists(path)) {
78 const json = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
79 now = json[NAME] === false
80 }
81 } catch {
82 now = off
83 }
84 if (now === off) return
85 off = now
86 try {
87 $.ui.invalidate('ui.render')
88 } catch {
89 // The next state change redraws anyway.
90 }
91}
92
93const words = (text: string) => text.trim().split(/\s+/).filter(Boolean).length
94
95// The person's own prompt: typed at the box, or sent from their phone.
96const fromPerson = (e: PromptSubmitInput) => e.origin.kind === 'composer' || e.origin.kind === 'bridge'
97
98export const isBrainDump = (text: string) => {
99 const t = text.trim()
100 if (t.startsWith('/')) return false
101 if (t.includes('THE JOB')) return false
102 return words(t) >= MIN_WORDS
103}
104
105// Holds the model to the house style even when it slips.
106const stripFences = (raw: string) =>
107 raw
108 .trim()
109 .replace(/^```[a-z]*\n?/i, '')
110 .replace(/\n?```$/, '')
111 .trim()
112
113export const houseStyle = (raw: string) => {
114 let t = raw
115 t = t.replace(/\s*[—–]\s*/g, ', ')
116 t = t.replace(/\bship(s|ped|ping)?\b/gi, (w, end: string | undefined) => {
117 const base = end === 's' ? 'delivers' : end === 'ped' ? 'delivered' : end === 'ping' ? 'delivering' : 'deliver'
118 return w.startsWith('S') ? `D${base.slice(1)}` : base
119 })
120 return t
121}
122
123export const tidy = (raw: string) => {
124 const t = houseStyle(stripFences(raw))
125 const start = t.indexOf('THE JOB')
126 return start > 0 ? t.slice(start) : t
127}
128
129const hasAllHeadings = (text: string) => HEADINGS.every(h => text.includes(h))
130
131async function log($: EngineInterface, line: string) {
132 try {
133 const dir = `${home}/.claude/mods-logs`
134 const path = `${dir}/brain-dump.log`
135 let old = ''
136 if (await $.fs.exists(path).catch(() => false)) old = String(await $.fs.read(path))
137 else await $.process.run(['mkdir', '-p', dir])
138 await $.fs.write(path, `${old}${new Date().toISOString()} ${line}\n`)
139 } catch {
140 // Logging must never break a prompt.
141 }
142}
143
144async function isOn($: EngineInterface) {
145 const v = await $.store.get('enabled').catch(() => undefined)
146 return v !== false
147}
148
149const failWhy = (r: { reason: string; error?: string; status?: number | null }) =>
150 r.reason === 'api-error' ? `the model call failed (${r.error}${r.status ? ` ${r.status}` : ''})` : r.reason === 'aborted' ? 'the model call timed out' : 'the model gave no text'
151
152// Reads step A's reply. Throws on anything that is not the JSON we asked for;
153// the caller turns that into zero questions.
154export const parseGaps = (raw: string): Gap[] => {
155 const t = stripFences(raw)
156 const a = t.indexOf('{')
157 const b = t.lastIndexOf('}')
158 if (a < 0 || b <= a) throw new Error('no JSON object in the reply')
159 const json = JSON.parse(t.slice(a, b + 1)) as { questions?: unknown }
160 if (!Array.isArray(json.questions)) throw new Error('no questions list in the reply')
161 const out: Gap[] = []
162 for (const item of json.questions) {
163 if (out.length >= MAX_QUESTIONS) break
164 const { q, options } = (item ?? {}) as { q?: unknown; options?: unknown }
165 if (typeof q !== 'string' || !q.trim() || !Array.isArray(options)) continue
166 const opts: string[] = []
167 for (const o of options) {
168 const label = typeof o === 'string' ? houseStyle(o.trim()) : ''
169 if (!label || /^other$/i.test(label) || opts.includes(label)) continue
170 if (opts.length < 3) opts.push(label)
171 }
172 if (opts.length < 2) continue
173 let question = houseStyle(q.trim())
174 if (!question.endsWith('?')) question = `${question}?`
175 out.push({ q: question, options: opts })
176 }
177 return out
178}
179
180// Step A. Never throws: a failed call or bad JSON means zero questions, logged.
181async function findGaps($: EngineInterface, dump: string): Promise<Gap[]> {
182 try {
183 const r = await $.model.complete({
184 model: MODEL,
185 system: GAPS_SYSTEM,
186 prompt: `Here is the brain dump. Return the JSON.\n\n<dump>\n${dump}\n</dump>`,
187 maxTokens: 800,
188 effort: 'low',
189 timeoutMs: 45_000,
190 })
191 if (!r.isAnswered) {
192 await log($, `gap check failed: ${failWhy(r)}; going on with 0 questions`)
193 return []
194 }
195 try {
196 return parseGaps(r.text)
197 } catch (err) {
198 const why = err instanceof Error ? err.message : String(err)
199 await log($, `gap check parse failed: ${why}; reply began ${JSON.stringify(r.text.slice(0, 80))}; going on with 0 questions`)
200 return []
201 }
202 } catch (err) {
203 await log($, `gap check threw: ${err instanceof Error ? err.message : String(err)}; going on with 0 questions`)
204 return []
205 }
206}
207
208// Asks each gap in the engine's dialog. A typed answer under "Other" comes back
209// as the text itself. Dismissing one skips it.
210async function askGaps($: EngineInterface, gaps: Gap[]): Promise<QA[]> {
211 const out: QA[] = []
212 for (const [i, g] of gaps.entries()) {
213 $.ui.status(`Shaping your brain dump: question ${i + 1} of ${gaps.length}`)
214 let a: string | null = null
215 try {
216 const said = (await $.ui.ask(g.q, { options: g.options, header: askHeader(i + 1, gaps.length) })).trim()
217 a = said || null
218 } catch {
219 a = null
220 }
221 out.push({ q: g.q, a })
222 }
223 return out
224}
225
226export const briefPrompt = (dump: string, qa: QA[]) => {
227 let p = `Here is the brain dump. Rewrite it into the brief.\n\n<dump>\n${dump}\n</dump>`
228 if (qa.length) {
229 const lines = qa.map(x => `Q: ${x.q}\nA: ${x.a ?? SKIPPED}`).join('\n\n')
230 p += `\n\nI asked the person these follow-up questions first. Use their answers in the brief. For a skipped one, write that point as "unknown: ask me" in the part it belongs to.\n\n<answers>\n${lines}\n</answers>`
231 }
232 return p
233}
234
235// Step B: the brief itself, now with the answers.
236async function shape($: EngineInterface, dump: string, qa: QA[]): Promise<string> {
237 const r = await $.model.complete({
238 model: MODEL,
239 system: SYSTEM,
240 prompt: briefPrompt(dump, qa),
241 maxTokens: 2000,
242 effort: 'low',
243 timeoutMs: 60_000,
244 })
245 if (!r.isAnswered) throw new Error(failWhy(r))
246 const brief = tidy(r.text)
247 if (!hasAllHeadings(brief)) throw new Error('the brief came back without all four parts')
248 return brief
249}
250
251export const register: Register = on => {
252 on('session.start', async ($, e, next) => {
253 const result = await next(e)
254 home = $.plugin.root.split('/.claude/')[0] ?? ''
255 await $.command.register({ name: CMD, description: 'Turn brain dump shaping on or off' })
256 await $.state.set(ENABLED, await isOn($))
257 await readSwitch($).catch(() => undefined)
258 $.clock.every(3000, () => {
259 void readSwitch($).catch(() => undefined)
260 })
261 return result
262 })
263
264 on('command.run', { command: CMD }, async $ => {
265 if (off) return { text: 'Brain-Dump Shaper is off. Turn it on with /mods.' }
266 const now = !(await isOn($))
267 await $.store.set('enabled', now)
268 await $.state.set(ENABLED, now)
269 const text = now ? 'Brain dump shaping is on.' : 'Brain dump shaping is off.'
270 $.ui.toast(text)
271 return { text }
272 })
273
274 on('prompt.submit', async ($, e, next) => {
275 if (off) return next(e)
276 if (!fromPerson(e) || !isBrainDump(e.text)) return next(e)
277 if (!(await isOn($))) return next(e)
278
279 let answer = ''
280 try {
281 answer = await $.ui.ask(QUESTION, { options: [SHAPE, AS_IS], header: 'Brain dump' })
282 } catch {
283 // Dismissed, or no one to ask: send it as typed.
284 return next(e)
285 }
286 if (answer !== SHAPE) return next(e)
287
288 const started = Date.now()
289 const secs = () => `${((Date.now() - started) / 1000).toFixed(1)}s`
290 let brief = ''
291 let qa: QA[] = []
292 try {
293 $.ui.toast(STATUS_START)
294 $.ui.status(STATUS_GAPS)
295 const gaps = await findGaps($, e.text)
296 qa = await askGaps($, gaps)
297 $.ui.status(STATUS_WRITE)
298 brief = await shape($, e.text, qa)
299 } catch (err) {
300 const why = err instanceof Error ? err.message : String(err)
301 $.ui.status(undefined)
302 $.ui.toast(`Could not shape it: ${why}. Sent as is.`)
303 await log($, `shape failed after ${secs()} (${qa.length} questions): ${why}`)
304 return next(e)
305 }
306 $.ui.status(undefined)
307 const skipped = qa.filter(x => x.a === null).length
308 await log($, `shaped in ${secs()}: ${qa.length} questions, ${qa.length - skipped} answered, ${skipped} skipped`)
309
310 const filled = await $.prompt.fill({ text: brief, mode: 'replace' }).catch((err: unknown) => {
311 void log($, `prompt.fill threw: ${err instanceof Error ? err.message : String(err)}`)
312 return null
313 })
314 if (!filled?.isFilled) {
315 const why = filled?.refusal ? `the prompt box refused it (${filled.refusal})` : 'the prompt box did not take it'
316 $.ui.toast(`Could not shape it: ${why}. Sent as is.`)
317 await log($, `prompt.fill refused: ${why}`)
318 return next(e)
319 }
320
321 $.ui.toast(DONE_TOAST)
322 return { drop: DROP_REASON }
323 })
324}
325types/index.d.ts 11 lines1export type BrainDumpState = {
2 /** False after /shape turns the mod off. Mirrors the store. */
3 enabled: boolean
4}
5
6declare module 'claude-code' {
7 interface PluginState {
8 "brain-dump": { enabled: boolean }
9 }
10}
11