SLOPSHOPPER

molt

When context outgrows its shell, shed it: save state to .state/, compact in place, keep going. One criterion, one call, no new session.

newbandcommandtoaststatusprompt
v0.2.3no licenseupdated 2026-10-07wisbech/molt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · molt
› 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 › /molt-lint ⎿ molt: .state/: 0 note(s), INDEX.md 0/200 lines, context 97k, molts 0, ceiling 150k (floor 97k, growth 5k/call, molt ⎿ molt: clean. molt · ctx 97k · sheds at 150k (solved) or 50 min idle [ Molt now ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ molt: ctx 97k

Draws

Band
molt · ctx 97k · sheds at 150k (solved) or 50 min idle [ Molt now ]
README

molt

When context outgrows its shell, shed it into the folder and keep going.

A Claude Code mod. Two criteria, one call, no new session, nothing to remember.

The problem, measured

If your Claude Code usage looks like this, the cache is not your problem:

Tokens, one project, one week158.7M
Cache hit rate96%
Cache read152.2M
Cache write5M
Uncached input0.7M
Output0.8M

That is a healthy cache. Only 4% of input missed it. Yet 96% of all tokens were cache reads, and cache reads are what you pay for a long thread: every API call resends the whole conversation. A hit is cheaper than a miss, but half a million tokens of hits per call, hundreds of times a day, is still the bill.

Parsing the session transcripts (~/.claude/projects/<project>/*.jsonl, one usage per assistant message) found where it went. One thread had been kept open for seven days:

The seven-day thread
API calls618
Average context per call~480k tokens
Peak context966k of a 1M window
Cache reads296M
Full cache rewrites14, each 550k to 850k tokens
Subagents spawned, all on the most expensive model61

Two mechanisms did the damage.

1. Thread length. Every tool call re-read ~480k tokens. A fresh session costs ~70k tokens of system prompt, tools, skills and memory. Resuming the thread cost 480k on the first message and on every call after it. The second-largest thread, same setup, averaged 230k per call and cost a quarter as much. Spend tracks thread length.

2. The cache expiring while you sleep. The prompt cache lives one hour on a subscription (five minutes on an API key). Every morning's first message rewrote the entire context at full write price. Fourteen such rewrites, 550k to 850k tokens each, every one after an idle gap over an hour. Auto-compact never helped: on a 1M-window model it fires at about 967k by default.

What it was not: the personal harness, hooks, voice calls and planning rituals accounted for under 4% of the tokens. Uninstalling them would have changed nothing. Measure before you blame your tooling.

What Claude Code already gives you

  • /autocompact 150k sets the auto-compact window per model and saves it.
  • /clear between unrelated tasks. /compact <focus> at natural breaks.
  • CLAUDE_CODE_SUBAGENT_MODEL=sonnet in settings.json env: subagents inherit the main model unless told otherwise.
  • CLAUDE_CODE_PROMPT_CACHE_TTL=1h where available.
  • /context and /usage to see what fills the window and what hits the cache.

Those fix the size. None of them fixes the idle rewrite, and all of them need you to remember. Molt is the part that runs itself.

The mechanism

Two criteria, one call.

  1. Idle. The prompt cache expires after an hour idle. The first call after that re-writes the whole context at full write price; in the trace above every expensive event was one of these. So 50 minutes into an idle stretch, while the cache is still warm and reads are cheap, the session molts. When you come back the rewrite is of a 60k context, not 500k.
  2. Size. Context tokens ≥ the ceiling, the backstop. The ceiling is solved, not set. Over a stretch between molts, tokens per call ≈ (F + T)/2 + m·T·g/(T − F): F the floor (context right after a molt), g the growth per API call, m the reads a molt costs. The minimum is T = F + √(2·m·g·F). Molt measures all three from usage and re-solves after every turn, so light chat lands near 95k and heavy subagent work near 165k on a 73k floor. Set threshold to a number to pin it instead.

The call is /molt. The model saves state to .state/; the mod then compacts the session in place with instructions to keep only what the handoff does not cover. Same session, same prompt box, next call carries a fraction of the tokens.

.state/
  INDEX.md      routing table: one line per note, says WHEN to read it (cap 200)
  HANDOFF.md    where the last stretch stopped (cap 60; rewritten by each molt)
  log.md        append-only history; grep it, never load it whole
  notes/*.md    decisions, failures, patterns; frontmatter links/files form the graph

INDEX.md and a fresh HANDOFF.md ride the first user message next to CLAUDE.md. A HANDOFF older than 7 days is marked stale, not trusted. The folder is harness-agnostic: any agent that reads markdown can continue from it.

Install

Paste this into Claude Code and let the agent do it:

Install the molt mod for Claude Code:
1. claude plugin marketplace add wisbech/molt
2. claude plugin install molt@molt
3. If the build says hooks modules are early access, add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" under "env" in ~/.claude/settings.json.
Then tell me whether /molt shows up in the command list.

By hand:

claude plugin marketplace add wisbech/molt
claude plugin install molt@molt

Or for one launch, without installing: claude --plugin-dir /path/to/molt.

Site: https://wisbech.github.io/molt/ · Tests: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .

Use

  • Nothing, usually. At a criterion the session molts by itself.
  • The bar above the prompt shows ctx 132k · molts at 120k (solved: …) and a Molt now button.
  • /molt [note] molts now, with a note for the next stretch.
  • /molt-lint is free deterministic housekeeping: index cap, orphans, broken links, superseded notes still routed, notes about deleted files, stale handoff. No model turn.
  • /molt-graph prints note -> links. Also free.
  • Options: idleMinutes (default 50; set to your cache TTL minus a margin), threshold (empty: solved; a number pins it), auto (off: button and command only).

Measure your own

python3 - <<'EOF'
import json, glob, os
d = os.path.expanduser('~/.claude/projects/')
for f in sorted(glob.glob(d + '*/*.jsonl'), key=os.path.getsize, reverse=True)[:5]:
    seen, n, cr, cw, mx = set(), 0, 0, 0, 0
    for line in open(f):
        try: o = json.loads(line)
        except: continue
        m = o.get('message') or {}; u = m.get('usage') or {}
        if o.get('type') != 'assistant' or not u or m.get('id') in seen: continue
        seen.add(m.get('id')); n += 1
        ctx = u.get('input_tokens', 0) + u.get('cache_read_input_tokens', 0) + u.get('cache_creation_input_tokens', 0)
        cr += u.get('cache_read_input_tokens', 0); cw += u.get('cache_creation_input_tokens', 0); mx = max(mx, ctx)
    print(f"{os.path.basename(f)[:8]} calls={n} avg_ctx={(cr+cw)/max(n,1)/1e3:.0f}k peak={mx/1e3:.0f}k cache_read={cr/1e6:.0f}M cache_write={cw/1e6:.1f}M")
EOF

If avg_ctx is a few hundred k, you have the problem above.

Borrowed from

  • wisbech/serf: "the folder is the state"; bandit's folder-router: folders as routing table
  • karpathy LLM wiki: index + append-only log + lint
  • Anthropic harness guidance: progress file, fixed startup routine, git as the safety net
  • Graphiti: supersede, never delete
  • The evidence that context files restating the code cost more and help less (ETH Zurich, arXiv 2602.11988), and that LLM-maintained documents decay with edits: hence small files, an immutable log, deterministic lint.
Source 1 files
hooks/register.tsx 350 lines
1// molt: when context outgrows its shell, shed it into the folder and keep going.
2//
3// Criteria, in order of what they save:
4//   1. idle: the prompt cache expires after an hour idle, and the first call after that
5//      re-writes the whole context at full price. So 50 minutes into an idle stretch,
6//      while the cache is still warm and reads are cheap, molt; the rewrite on return
7//      is then of a small context. This is where the money went in the traces.
8//   2. size: context tokens >= the ceiling, the backstop. The ceiling is solved, not set:
9//      over a stretch between molts, tokens per call ≈ (F + T)/2 + m·T·g/(T − F), where
10//      F is the floor (context right after a molt), g the growth per API call and m the
11//      reads a molt itself costs. Its minimum is T = F + sqrt(2·m·g·F). Molt measures
12//      F, g and m from usage and re-solves after every turn; `threshold` pins it instead.
13// Call: the /molt command (commands/molt.md) has the model save state to .state/,
14//       then this module compacts the session in place. Same session, small context.
15//
16// .state/INDEX.md    routing table, one line per note, says WHEN to read it (cap 200)
17// .state/HANDOFF.md  where the last stretch stopped (cap 60, rewritten by each molt)
18// .state/log.md      append-only history, greppable, never injected whole
19// .state/notes/*.md  decisions, failures, patterns; frontmatter links/files = the graph
20import type { EngineInterface as Engine, Register } from 'claude-code'
21
22const DIR = '.state'
23const INDEX_CAP = 200
24const HANDOFF_CAP = 60
25const STALE_DAYS = 7
26const DAY = 86_400_000
27const IDLE_FLOOR = 50_000 // below this a fresh session costs about the same; nothing to shed
28
29type Note = { id: string; path: string; title: string; status: string; links: string[]; files: string[] }
30type Lint = { problems: string[]; notes: Note[]; indexLines: number }
31
32// Module state. A reload starts it over, which is fine: it is all derivable.
33const S = {
34  section: null as string | null, // the context block; re-read after every molt
35  ctx: 0,
36  phase: 'idle' as 'idle' | 'shedding',
37  molts: 0,
38  last: '',
39  idleTimer: null as { cancel: () => void } | null,
40  // the sweet-spot inputs
41  floor: 0, // F: context at the first turn after a start or a molt
42  isFloorPending: true,
43  growth: 5_000, // g: tokens added per API call, EMA
44  moltReads: 3, // m: reads one molt costs (the handoff turn's calls + the compaction), EMA
45  ceiling: 150_000, // T: solved, or pinned by the threshold option
46}
47
48const k = (n: number) => `${Math.round(n / 1000)}k`
49const ema = (old: number, next: number, a = 0.3) => old + a * (next - old)
50
51// Tokens read this turn across all its API calls, and how many calls that was.
52function turnReads(usage: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined, ctxBefore: number, ctxAfter: number) {
53  if (!usage) return null
54  const reads = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
55  const avgCtx = Math.max(1, (Math.max(ctxBefore, 1) + Math.max(ctxAfter, 1)) / 2)
56  return { reads, calls: Math.max(1, Math.round(reads / avgCtx)) }
57}
58
59// T = F + sqrt(2·m·g·F), kept inside sane bounds and rounded to 5k.
60export function solveCeiling(floor: number, growth: number, moltReads: number): number {
61  const f = Math.max(20_000, floor)
62  const t = f + Math.sqrt(2 * Math.max(1, moltReads) * Math.max(500, growth) * f)
63  return Math.round(Math.min(400_000, Math.max(f + 20_000, t)) / 5_000) * 5_000
64}
65
66function frontmatter(text: string): Record<string, string> {
67  const m = /^---\n([\s\S]*?)\n---/.exec(text)
68  const out: Record<string, string> = {}
69  for (const line of (m?.[1] ?? '').split('\n')) {
70    const i = line.indexOf(':')
71    if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim()
72  }
73  return out
74}
75
76// `links: [a, b]`, `links: a, b`, plus [[wikilinks]] in the body
77function listOf(value: string | undefined, body: string, wiki: boolean): string[] {
78  const items = (value ?? '')
79    .replace(/^\[|\]$/g, '')
80    .split(/[,\s]+/)
81    .map(s => s.trim().replace(/^["']|["']$/g, ''))
82    .filter(Boolean)
83  if (wiki) for (const w of body.matchAll(/\[\[([^\]|#]+)/g)) items.push((w[1] ?? '').trim())
84  return [...new Set(items.filter(Boolean))]
85}
86
87async function readText($: Engine, path: string): Promise<string> {
88  return String(await $.fs.read(path))
89}
90
91async function readCapped($: Engine, path: string, cap: number): Promise<string | null> {
92  if (!(await $.fs.exists(path))) return null
93  const lines = (await readText($, path)).split('\n')
94  if (lines.length <= cap) return lines.join('\n')
95  return `${lines.slice(0, cap).join('\n')}\n… ${lines.length - cap} more lines cut. Keep this file under ${cap} lines.`
96}
97
98async function ageDays($: Engine, path: string): Promise<number> {
99  return ((await $.clock.now()) - (await $.fs.stat(path)).mtimeMs) / DAY
100}
101
102async function readNotes($: Engine): Promise<Note[]> {
103  if (!(await $.fs.exists(`${DIR}/notes`))) return []
104  const notes: Note[] = []
105  for (const entry of await $.fs.list(`${DIR}/notes`)) {
106    if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
107    const text = await readText($, `${DIR}/notes/${entry.name}`)
108    const fm = frontmatter(text)
109    const id = entry.name.replace(/\.md$/, '')
110    notes.push({
111      id,
112      path: `notes/${entry.name}`,
113      title: fm.title ?? id,
114      status: fm.status ?? 'active',
115      links: listOf(fm.links, text, true).map(l => l.replace(/\.md$/, '').replace(/^notes\//, '')),
116      files: listOf(fm.files, '', false),
117    })
118  }
119  return notes
120}
121
122// Deterministic housekeeping: no model, no tokens.
123async function lint($: Engine): Promise<Lint> {
124  const problems: string[] = []
125  const notes = await readNotes($)
126  const ids = new Set(notes.map(n => n.id))
127  const index = (await $.fs.exists(`${DIR}/INDEX.md`)) ? await readText($, `${DIR}/INDEX.md`) : null
128  const indexLines = index ? index.split('\n').filter((l: string) => l.trim()).length : 0
129
130  if (index === null) {
131    if (notes.length) problems.push(`no ${DIR}/INDEX.md but ${notes.length} note(s): nothing routes to them`)
132  } else {
133    if (indexLines > INDEX_CAP) problems.push(`INDEX.md has ${indexLines} lines, cap is ${INDEX_CAP}: merge or archive`)
134    for (const m of index.matchAll(/\]\(([^)]+\.md)\)/g)) {
135      const target = (m[1] ?? '').replace(/^\.\//, '')
136      if (!(await $.fs.exists(`${DIR}/${target}`))) problems.push(`INDEX.md links to missing ${target}`)
137    }
138    for (const n of notes) {
139      const routed = index.includes(n.path)
140      if (!routed && n.status === 'active') problems.push(`orphan: ${n.path} is not in INDEX.md`)
141      if (routed && n.status !== 'active') problems.push(`${n.path} is ${n.status} but still in INDEX.md`)
142    }
143  }
144  for (const n of notes) {
145    for (const l of n.links) if (!ids.has(l)) problems.push(`${n.path} links to unknown note "${l}"`)
146    for (const f of n.files) if (!(await $.fs.exists(f))) problems.push(`${n.path} is about ${f}, which no longer exists`)
147  }
148  if (await $.fs.exists(`${DIR}/HANDOFF.md`)) {
149    const age = await ageDays($, `${DIR}/HANDOFF.md`)
150    if (age > STALE_DAYS) problems.push(`HANDOFF.md is ${Math.round(age)} days old: verify against git log before trusting it`)
151  }
152  return { problems, notes, indexLines }
153}
154
155// What a fresh stretch reads: the routing table and where the last one stopped.
156async function compose($: Engine): Promise<string | null> {
157  const index = await readCapped($, `${DIR}/INDEX.md`, INDEX_CAP)
158  let handoff: string | null = null
159  if (await $.fs.exists(`${DIR}/HANDOFF.md`)) {
160    const age = await ageDays($, `${DIR}/HANDOFF.md`)
161    handoff =
162      age <= STALE_DAYS
163        ? await readCapped($, `${DIR}/HANDOFF.md`, HANDOFF_CAP + 20)
164        : `(${Math.round(age)} days old, stale. Check git log before trusting it.)`
165  }
166  if (index === null && handoff === null) return null
167  return [
168    `The folder is the state. ${DIR}/INDEX.md is the routing table: one line per note saying when to read it; open a note only when its line applies. ${DIR}/HANDOFF.md is where the last stretch stopped. ${DIR}/log.md is append-only history; grep it, never load it whole. When context grows past the threshold or the session idles, it molts: /molt saves state here, then the session compacts.`,
169    index !== null ? `## ${DIR}/INDEX.md\n${index}` : '',
170    handoff !== null ? `## ${DIR}/HANDOFF.md\n${handoff}` : '',
171  ]
172    .filter(Boolean)
173    .join('\n\n')
174}
175
176const COMPACT_INSTRUCTIONS = `The project state was just saved to ${DIR}/HANDOFF.md and ${DIR}/INDEX.md, which the conversation carries. Keep only: the user's latest request, what is in progress right now, files currently being edited, and any tool result the very next step needs. Drop everything the handoff covers.`
177
178// Shedding runs as its own turn once the session is idle; a timer outlives the dispatch.
179function shed($: Engine): void {
180  if (S.phase === 'shedding') return
181  S.phase = 'shedding'
182  S.idleTimer?.cancel()
183  S.idleTimer = null
184  $.ui.invalidate('ui.render')
185  $.clock.after(1, () => void $.prompt.submit({ text: '/molt' }))
186}
187
188// The free answers: lint and graph never touch the model.
189async function answer($: Engine, what: 'lint' | 'graph', ceiling: string): Promise<{ text: string }> {
190  const { problems, notes, indexLines } = await lint($)
191  if (what === 'graph') return { text: graphText(notes) }
192  const head = `${DIR}/: ${notes.length} note(s), INDEX.md ${indexLines}/${INDEX_CAP} lines, context ${k(S.ctx)}, molts ${S.molts}, ceiling ${ceiling}`
193  return { text: problems.length ? `${head}\n${problems.map(p => `- ${p}`).join('\n')}` : `${head}\nclean.` }
194}
195
196// The model is about to save state; the turn that follows ends in a compaction.
197function shedding($: Engine): void {
198  S.phase = 'shedding'
199  S.idleTimer?.cancel()
200  S.idleTimer = null
201  $.ui.invalidate('ui.render')
202}
203
204function graphText(notes: Note[]): string {
205  if (!notes.length) return `${DIR}/notes/ is empty.`
206  return notes
207    .map(
208      n =>
209        `${n.id}${n.status !== 'active' ? ` (${n.status})` : ''} -> ${n.links.join(', ') || '(no links)'}${n.files.length ? `  files: ${n.files.join(', ')}` : ''}`,
210    )
211    .join('\n')
212}
213
214export const register: Register = (on, options) => {
215  const pinned = Number(options?.threshold) > 0 ? Math.max(20_000, Number(options?.threshold)) : 0 // 0 = solve it
216  if (pinned) S.ceiling = pinned
217  const idleMinutes = String(options?.idleMinutes ?? '').trim() === '0' ? 0 : Math.max(1, Number(options?.idleMinutes) || 50)
218  const isAuto = options?.auto !== false
219
220  // A new prompt means the person is back: the idle clock starts over at turn.complete.
221  on('prompt.submit', ($, e, next) => {
222    S.idleTimer?.cancel()
223    S.idleTimer = null
224    return next(e)
225  })
226
227  on('session.start', async ($, e, next) => {
228    // Registered commands raise command.run and answer without a model turn, in every mode.
229    // (/molt itself is the plugin's markdown command: a skill the model expands.)
230    await $.command.register({ name: 'molt-lint', description: `Lint ${DIR}/: index cap, orphans, broken links, stale handoff. Free.` })
231    await $.command.register({ name: 'molt-graph', description: `Print ${DIR}/notes links as note -> links. Free.` })
232    // Priors from earlier sessions; this session's floor is measured on its first turn.
233    const g = Number(await $.store.get('growth'))
234    const m = Number(await $.store.get('moltReads'))
235    if (g > 0) S.growth = g
236    if (m > 0) S.moltReads = m
237    S.isFloorPending = true
238    S.section = await compose($)
239    const { problems } = await lint($)
240    if (problems.length) $.ui.toast(`${DIR}: ${problems.length} lint issue(s). Run /molt lint.`)
241    return next(e)
242  })
243
244  // Rides the first user message next to claudeMd; changes only when a molt rewrote the files.
245  on('prompt.context', async ($, e, next) => {
246    const r = await next(e)
247    if (S.section === null) return r
248    return { ...r, blocks: [...r.blocks, { name: 'molt', text: S.section }] }
249  })
250
251  on('turn.complete', async ($, e, next) => {
252    if (e.agentId) return next(e)
253    const ctxBefore = S.ctx
254    const { context } = await $.session.usage()
255    S.ctx = context.tokens ?? 0
256    const t = turnReads(e.usage, ctxBefore, S.ctx)
257
258    if (S.isFloorPending && S.ctx > 0) {
259      S.floor = S.ctx
260      S.isFloorPending = false
261    } else if (S.phase === 'idle' && t && S.ctx > ctxBefore) {
262      S.growth = ema(S.growth, (S.ctx - ctxBefore) / t.calls)
263      void $.store.set('growth', S.growth)
264    }
265
266    if (S.phase === 'shedding') {
267      S.phase = 'idle'
268      if (e.reason === 'answer') {
269        const before = S.ctx
270        if (t) {
271          S.moltReads = ema(S.moltReads, t.calls + 1) // the handoff turn's calls, plus the compaction
272          void $.store.set('moltReads', S.moltReads)
273        }
274        S.section = await compose($)
275        const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
276        if ('skip' in r) {
277          S.last = `molt: compaction skipped (${r.skip})`
278        } else {
279          S.molts += 1
280          S.ctx = r.tokensAfter ?? S.ctx
281          S.isFloorPending = true // the next turn measures the new floor
282          S.last = `molted ${k(before)} → ${k(S.ctx)}`
283        }
284        $.ui.toast(S.last)
285      } else {
286        S.last = `molt interrupted (${e.reason})`
287      }
288    }
289
290    if (!pinned) S.ceiling = solveCeiling(S.floor || S.ctx, S.growth, S.moltReads)
291
292    if (S.phase === 'idle' && isAuto && S.ctx >= S.ceiling) {
293      shed($)
294    } else if (S.phase === 'idle' && isAuto && idleMinutes > 0 && S.ctx >= IDLE_FLOOR) {
295      // Molt before the cache goes cold, not after.
296      S.idleTimer?.cancel()
297      S.idleTimer = $.clock.after(idleMinutes * 60_000, () => {
298        S.idleTimer = null
299        S.last = `idle ${idleMinutes} min, molting before the cache expires`
300        shed($)
301      })
302    }
303
304    $.ui.status(`ctx ${k(S.ctx)}${S.molts ? ` · molts ${S.molts}` : ''}`)
305    $.ui.invalidate('ui.render')
306    return next(e)
307  })
308
309  // The bar: always there once the mod is loaded, so "is molt on?" has a visible answer.
310  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
311    if (e.props.hasSurvey) return next(e)
312    const { Box, Button, Text } = $.ui.resolve(e)
313    const text =
314      S.phase === 'shedding'
315        ? `molt · saving ${DIR}/HANDOFF.md, then compacting…`
316        : `molt · ctx ${k(S.ctx)} · sheds at ${k(S.ceiling)}${pinned ? '' : ' (solved)'} or ${idleMinutes} min idle${S.molts ? ` · molts ${S.molts}` : ''}${S.last ? ` · ${S.last}` : ''} `
317    return (
318      <Box>
319        <Text dimColor>{text}</Text>
320        {S.phase === 'idle' && <Button key="molt" label="Molt now" onPress={() => shed($)} />}
321      </Box>
322    )
323  })
324
325  const ceilingText = () =>
326    `${k(S.ceiling)}${pinned ? ' (pinned)' : ` (floor ${k(S.floor)}, growth ${k(S.growth)}/call, molt ${S.moltReads.toFixed(1)} reads)`}`
327
328  // Names may come namespaced (molt:molt-lint) from an installed plugin, bare from --plugin-dir.
329  on('command.run', { command: ['molt-lint', 'molt:molt-lint'] }, $ => answer($, 'lint', ceilingText()))
330  on('command.run', { command: ['molt-graph', 'molt:molt-graph'] }, $ => answer($, 'graph', ceilingText()))
331
332  // A molt is starting: the model is about to save state. Fires however /molt was invoked
333  // (typed, submitted by the idle timer or the button, or called through the Skill tool).
334  on('skill.prompt', { skill: ['molt', 'molt:molt'] }, ($, e, next) => {
335    const arg = /The user adds: (\S*)/.exec(e.text)?.[1]
336    if (arg === 'lint' || arg === 'graph') {
337      // Typed as /molt lint where the skill path took it: hand the model the free answer to relay.
338      return answer($, arg, ceilingText()).then(r => ({ text: `Relay this to the user exactly, then stop:\n\n${r.text}` }))
339    }
340    shedding($)
341    return next(e)
342  })
343  on('command.run', { command: ['molt', 'molt:molt'] }, ($, e, next) => {
344    const arg = e.args.trim()
345    if (arg === 'lint' || arg === 'graph') return answer($, arg, ceilingText())
346    shedding($)
347    return next(e)
348  })
349}
350