SLOPSHOPPER

brain-dump

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

newcommandtoaststatuspromptmodel
v0.2.0no licenseupdated 2026-10-26Anirach/Anirach.github.io/.claude/skills/brain-dump
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brain-dump
› fix the failing auth test and add an audit log call ╭────────────────────────────╮ │ brain-dump │ ⏺ Read(src/auth.ts) │ Brain dump shaping is off. │ ⎿ 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 › /shape ⎿ brain-dump: Brain dump shaping is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 325 lines
1import 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}
325
types/index.d.ts 11 lines
1export 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