M1K3 inside Claude Code: the CLAUDE.md nevers as enforced tool.call rules (a direct push to master, a Write over the append-only session memory, hf download…

A Claude Code plugin of function hooks (a "mod": one register(on, options) hooking the engine's events as ($, e, next) functions). It loads by itself in any Claude Code session opened in this checkout, from this folder, and hot-reloads when a file here changes. Three things, one plugin:
| Part | What it does | Where |
|---|---|---|
| guard | CLAUDE.md's nevers as tool.call denies: a direct push to master (+master and refs/heads/master included), a Write or Edit over the append-only .claude/project-memory.md (or a > redirect onto it, or a git add -f of it), hf download, defaults write app.m1k3, an osascript that tells app.m1k3 to quit. Judgement calls (a master merge, gh pr merge --delete-branch, a plain --force) toast instead. Rules read the command with its quoted spans and heredoc bodies blanked, so a commit message or PR body that mentions a phrase never trips one. On session.start the status line says when M1K3.xcodeproj is missing or older than project.yml. | hooks/guard.ts |
| voice | When a session needs you (a permission prompt, an idle prompt) or ends a turn over 20 s, or fails, the live app says so through its own MCP server (speak, the m1k3 server m1k3 connect claude registers). A finished turn says only "Done after N seconds." unless readAnswers is on, which adds the answer's first sentence. A speak that has not answered in 8 s, a line within 10 s of the last, or an unreachable app all fall back to a toast. Speaking needs an allow rule for mcp__m1k3__speak in ~/.claude/settings.json: the mod asks the permission check first and, without the rule, toasts the line plus a one-time hint, since a hook's call has no one to ask (auto mode refuses it between turns). voice: false in the plugin's config turns it off. | hooks/voice.ts, the call in hooks/register.tsx |
| avatar band | The band above the prompt shows M1K3 reacting to the session: thinking on turn.start, generating while a tool runs, listening on a permission prompt, the error face on a failed tool, a happy beat on turn.complete, sleepy after a quiet half hour, speaking while the app speaks. The pixel face is M1K3Avatar's FaceExpression, ported; the fox is baked frames of the Khronos Fox through ClipMapper's fox dialect. | hooks/register.tsx, face-math.ts, raster.ts, companion.ts, avatar-state.ts |
It is a guard against the accidental, not the determined. A tee, cp, mv, sed -i or rm on the session memory passes; so does a push to master from a script file, or one wrapped in bash -c "…", eval, git -C <dir> or git -c k=v (the rules read a bare git push at a command boundary: a line start, ;, &&, ||, |, ( or a newline). The AppleScript rule reads the raw command, so prose that carries both osascript and the quit phrase trips it; put such text in a file. The Edit deny goes one step past CLAUDE.md's wording (which names Write as what lost 700 lines) because an Edit can drop a block just as silently; a typo in the chronicle stays, the chronicle being append-only.
/face hides or shows the band (/face off, /face on)./companion face|fox|phosphor|off picks the avatar; the pick is kept across sessions.| Surface | Face | Companion |
|---|---|---|
| Terminal, kitty graphics (kitty, Ghostty) | Raster: 13 × 6 cells of ▀, two LED rows per cell, repainted by $.ui.blit at 30 fps active / 2 fps idle | Image by file path, one PNG per frame, swapped by $.ui.blit at the clip's rate; the terminal reads the file itself |
| Any other terminal | the same Raster | a braille Raster (24 × 6 cells, 48 × 24 dots, one colour). The first blit an Image refuses ("draws its alt") drops the session to this tier for good, and the choice is remembered |
| Desktop, VS Code, mobile | Svg of rounded rects, redrawn up to 4 times a second while active | Svg holding an 80 × 40 sprite strip with an SMIL animate on its x offset: it plays itself |
The colours are AvatarEmotion.accentColor scaled by each cell's intensity over black, so the face reads on a dark terminal; a light theme gets a dark face. Rendering costs no GPU: everything is baked or arithmetic, in the spirit of AvatarPresence (nothing is painted while the band is hidden).
assets/ holds the fox's baked frames (both looks, three clips, 158 PNGs at 192 × 96), their braille reductions and the desktop strips, with assets/ATTRIBUTION.md. They are baked output: tools/bake/ regenerates them from the GLB (three.js in headless Chromium), and the other companions get the same run with their own GLB when they are wanted here.
claude plugin validate .claude/skills/m1k3 # what the engine would refuse
claude plugin test .claude/skills/m1k3 # tests/*.test.ts against the engine
tsc -p .claude/skills/m1k3 # once a session has laid .claude-plugin/types/
The engine writes .claude-plugin/types/ beside the plugin when it loads it (the API, the built-in tools, the connected MCP tools); it is gitignored. Two engine rules shape the code: one unmatched hook per event per module, and $ only ever spelled $.noun.method(...), never passed. So register.tsx owns every unmatched event and every engine call but the guard's own (guard.ts registers its three matched tool.call hooks and toasts from them), and avatar-state.ts, voice.ts, companion.ts, face-math.ts and raster.ts are pure.
hooks/register.tsx 364 lines1// The m1k3 mod: guard, voice, and the avatar band. The engine takes one
2// unmatched hook per event per module and never `$` as an argument, so every
3// event is registered here once and every `$` call is spelled here; the parts
4// (guard.ts, avatar-state.ts, voice.ts, companion.ts, face-math.ts, raster.ts)
5// are pure and say what to do. `session.start` binds the closures the other
6// hooks and the timers share (apply a mood change, say a line, paint a frame).
7//
8// Signed: Kev + Claude, 2026-10-05, Confidence 0.7 (the hooks load and the
9// pure parts are pinned; the band's look on each surface is verify-by-launch).
10// Prior: Unknown
11// Review: Kev + Claude, 2026-10-05 — summoned pass on #493: `speak` is bounded
12// (SPEAK_TIMEOUT_MS) and gapped (SPEAK_GAP_MS), both falling back to the toast;
13// answers are read aloud only under `readAnswers`. Confidence 0.7.
14// Review: Kev + claude-opus-5-5, 2026-10-06 — `sayLine` asks `$.tool.check` first
15// and speaks only on `allow`; otherwise it toasts the line, plus the rule's hint
16// once per load on an `ask` (a deny is the person's rule: no hint). The check runs
17// before the gap, so two lines at once still speak one. Pinned by "without an allow rule for speak…" (mutation-checked). The band
18// drawing and the fox are now seen live in Ghostty. Confidence 0.8 (speech with
19// the rule in place still owes Kev's ear).
20
21import { atom, read, update } from 'claude-code'
22import type { Register } from 'claude-code'
23
24import {
25 AVATARS, onNotification, onQuiet, onSessionStart, onSpeak, onToolEnd, onToolStart, onTurnComplete, onTurnStart,
26 type Change,
27} from './avatar-state'
28import {
29 BAND_COLUMNS, BAND_ROWS, BRAILLE_COLOUR, Loaded, braillePath, clipFor, clipMeta, frameIndex, framePath, manifestPath, parseBraille, parseManifest, stripPath,
30 type BrailleClip, type Look, type Manifest,
31} from './companion'
32import { isActive, statusLabel } from './face-math'
33import { registerGuard, xcodeprojMessage } from './guard'
34import { FACE_COLUMNS, FACE_ROWS, brailleCells, faceCells, faceSvg, spriteSvg } from './raster'
35import { SERVER, SPEAK_PERMISSION_HINT, SPEAK_TIMEOUT_MS, SPEAK_TOOL, maySpeak, speakingMs, voiceForNotification, voiceForTurn } from './voice'
36import type { Avatar, Mood } from '../types'
37
38// The session's values (types/index.d.ts is the contract). Consts of this
39// file, so the engine's scan can list what the module reads and writes.
40const avatar = atom({ plugin: 'm1k3', key: 'avatar' } as const, 'face' as Avatar)
41const mood = atom({ plugin: 'm1k3', key: 'mood' } as const, { emotion: 'neutral', activity: 'idle', since: 0 } as Mood)
42const isBandHidden = atom({ plugin: 'm1k3', key: 'isBandHidden' } as const, false)
43/** The band's second line: what M1K3 is reacting to, in a few words. */
44const note = atom({ plugin: 'm1k3', key: 'note' } as const, 'Waiting for you.')
45
46const AVATAR_STORE_KEY = 'm1k3.avatar'
47const TIER_STORE_KEY = 'm1k3.companionTier'
48
49type Tier = 'image' | 'braille'
50type Band = { requestId: string; surface: string }
51
52const isAvatar = (value: unknown): value is Avatar => typeof value === 'string' && (AVATARS as readonly string[]).includes(value)
53
54export const register: Register = (on, options) => {
55 registerGuard(on)
56
57 const isVoiceOn = options.voice !== false
58 const readsAnswers = options.readAnswers === true
59 let lastSpokeAt = -Infinity
60 let isSpeakHinted = false
61 let band: Band | undefined
62 let tier: Tier = 'image'
63 let root = ''
64 let settle: { cancel: () => void } | undefined
65 let lastActivityAt = 0
66 let clipStartedAt = 0
67 let lastClip = ''
68 let lastPaintAt = 0
69 let lastDesktopAt = 0
70 const manifests = new Loaded<Manifest>()
71 const brailles = new Loaded<BrailleClip>()
72 const strips = new Loaded<string>()
73
74 // Bound at session.start, where the session's `$` is; no-ops until then.
75 let applyChange: (change: Change) => Promise<void> = async () => undefined
76 let sayLine: (text: string, emotion: string) => Promise<void> = async () => undefined
77
78 on('session.start', async ($, e, next) => {
79 root = $.plugin.root
80 lastActivityAt = await $.clock.now()
81 const stored = await $.store.get(AVATAR_STORE_KEY).catch(() => undefined)
82 const initial: Avatar = isAvatar(stored) ? stored : isAvatar(options.avatar) ? options.avatar : 'face'
83 await update($, avatar, () => initial)
84 if ((await $.store.get(TIER_STORE_KEY).catch(() => undefined)) === 'braille') tier = 'braille'
85
86 applyChange = async change => {
87 const now = await $.clock.now()
88 settle?.cancel()
89 settle = undefined
90 await update($, mood, () => ({ ...change.state, since: now }))
91 if (change.note !== undefined) await update($, note, () => change.note ?? '')
92 const then = change.then
93 if (then !== undefined) {
94 settle = $.clock.after(then.afterMs, async () => {
95 // Only settle what this change set; a later event may have moved on.
96 if ((await read($, mood)).since !== now) return
97 await update($, mood, () => ({ ...then.state, since: now + then.afterMs }))
98 if (then.note !== undefined) await update($, note, () => then.note ?? '')
99 })
100 }
101 }
102
103 // Only a rule lets a hook's `speak` run: an `ask` has no one to ask (auto
104 // mode fails it closed between turns, default mode would raise a dialog for
105 // a status line). The query itself asks no one. A check that fails lets the
106 // call try; its own timeout and toast bound it.
107 const speakDecision = async (input: { text: string; emotion: string }) => {
108 try {
109 return (await $.tool.check({ tool: SPEAK_TOOL, input })).decision
110 } catch {
111 return 'allow' as const
112 }
113 }
114
115 sayLine = async (text, emotion) => {
116 const decision = await speakDecision({ text, emotion })
117 if (decision !== 'allow') {
118 $.ui.toast(text, { timeoutMs: 6000 })
119 // A deny is the person's own rule; only an `ask` has a rule to add.
120 if (decision === 'ask' && !isSpeakHinted) {
121 isSpeakHinted = true
122 $.ui.toast(SPEAK_PERMISSION_HINT, { timeoutMs: 12000 })
123 }
124 return
125 }
126 // Speech behind speech is noise: a line within the gap toasts instead. A
127 // `speak` that stalls (a wedged voice engine, #471) is bounded the same way.
128 // Nothing awaits between reading the gap and spending it.
129 const now = await $.clock.now()
130 if (!maySpeak(now, lastSpokeAt)) {
131 $.ui.toast(text, { timeoutMs: 6000 })
132 return
133 }
134 lastSpokeAt = now
135 // The call itself cannot be cancelled: a stalled engine that recovers
136 // late speaks after the toast, and that is accepted. The timer can be.
137 let timer: { cancel: () => void } | undefined
138 try {
139 const result = await Promise.race([
140 $.mcp.call(SERVER, 'speak', { text, emotion }),
141 new Promise<never>((_, reject) => {
142 timer = $.clock.after(SPEAK_TIMEOUT_MS, () => reject(new Error('speak timed out')))
143 }),
144 ])
145 if (result.isError) throw new Error('speak refused')
146 await applyChange(onSpeak(text, speakingMs(text)))
147 } catch {
148 $.ui.toast(text, { timeoutMs: 6000 })
149 } finally {
150 timer?.cancel()
151 }
152 }
153
154 await applyChange(onSessionStart())
155
156 // `xcodegen` after every checkout: the project file is a gitignored artifact.
157 const [hasSpec, hasProject] = await Promise.all([$.fs.exists('macos/project.yml'), $.fs.exists('macos/M1K3.xcodeproj/project.pbxproj')])
158 const specMtime = hasSpec ? (await $.fs.stat('macos/project.yml')).mtimeMs : 0
159 const projectMtime = hasProject ? (await $.fs.stat('macos/M1K3.xcodeproj/project.pbxproj')).mtimeMs : 0
160 const stale = xcodeprojMessage(hasSpec, hasProject, specMtime, projectMtime)
161 if (stale !== undefined) $.ui.status(stale)
162
163 await $.command.register({ name: 'face', description: 'Hide or show the M1K3 band above the prompt' })
164 await $.command.register({ name: 'companion', description: 'Pick the M1K3 avatar: face, fox, phosphor or off', argumentHint: '[face|fox|phosphor|off]' })
165
166 // Idle life: a quiet half hour drifts the face to sleepy; the next turn wakes it.
167 $.clock.every(60_000, async () => {
168 const change = onQuiet(await read($, mood), await $.clock.now(), lastActivityAt)
169 if (change !== undefined) await applyChange(change)
170 })
171
172 // The band's clock: the face at 30 fps while active and 2 fps idle, a
173 // companion at its clip's own rate. Nothing is painted while hidden.
174 $.clock.every(33, async () => {
175 if (band === undefined) return
176 const which = await read($, avatar)
177 if (which === 'off' || (await read($, isBandHidden))) return
178 const current = await read($, mood)
179 const now = await $.clock.now()
180 const lively = isActive(current.activity) || current.activity === 'error'
181
182 if (band.surface !== 'terminal') {
183 // No blit on a remote surface: redraw the Svg a few times a second while
184 // the face is active (a companion's SMIL sprite animates by itself).
185 if (which !== 'face') return
186 const every = lively ? 250 : 1000
187 if (now - lastDesktopAt < every) return
188 lastDesktopAt = now
189 $.ui.invalidate('ui.render')
190 return
191 }
192
193 if (which === 'face') {
194 const every = lively ? 33 : 500
195 if (now - lastPaintAt < every) return
196 lastPaintAt = now
197 await $.ui.blit({ requestId: band.requestId, key: 'face', cells: faceCells(current, now / 1000) })
198 return
199 }
200
201 const look: Look = which
202 const clip = clipFor(current)
203 if (clip !== lastClip) {
204 lastClip = clip
205 clipStartedAt = now
206 }
207 const meta = clipMeta(await manifests.get(root, () => $.fs.read(manifestPath(root)).then(parseManifest)), look, clip)
208 const every = Math.max(33, (meta.duration * 1000) / meta.frames)
209 if (now - lastPaintAt < every) return
210 lastPaintAt = now
211 const index = frameIndex(meta, (now - clipStartedAt) / 1000)
212
213 if (tier === 'image') {
214 const blit = await $.ui.blit({ requestId: band.requestId, key: 'fox', source: { file: framePath(root, look, clip, index), format: 'png' } })
215 if (blit.deny !== undefined && /\balt\b/.test(blit.deny)) {
216 // This terminal cannot draw pictures: drop to braille for good here.
217 tier = 'braille'
218 await $.store.set(TIER_STORE_KEY, tier).catch(() => undefined)
219 $.ui.invalidate('ui.render')
220 }
221 return
222 }
223 const path = braillePath(root, look, clip)
224 const braille = await brailles.get(path, () => $.fs.read(path).then(parseBraille))
225 await $.ui.blit({ requestId: band.requestId, key: 'fox', cells: brailleCells(braille.band[index] ?? [], BRAILLE_COLOUR[look]) })
226 })
227
228 return next(e)
229 })
230
231 on('turn.start', async ($, e, next) => {
232 lastActivityAt = await $.clock.now()
233 await applyChange(onTurnStart(e.text))
234 return next(e)
235 })
236
237 on('tool.call', async ($, e, next) => {
238 const current = await read($, mood)
239 const before = onToolStart(current, e.tool, e as unknown as Record<string, unknown>)
240 if (before !== undefined) await applyChange(before)
241 const ran = await next(e)
242 const after = onToolEnd(current.activity === 'idle', ran.deny === undefined && ran.isError === true, e.tool)
243 if (after !== undefined) await applyChange(after)
244 return ran
245 })
246
247 on('classic.Notification', async ($, e, next) => {
248 const change = onNotification(e.notification_type, e.message)
249 if (change !== undefined) await applyChange(change)
250 const line = isVoiceOn ? voiceForNotification(e.notification_type, e.message) : undefined
251 if (line !== undefined) void sayLine(line.text, line.emotion)
252 return next(e)
253 })
254
255 on('turn.complete', async ($, e, next) => {
256 lastActivityAt = await $.clock.now()
257 await applyChange(onTurnComplete(e.reason))
258 const line = isVoiceOn ? voiceForTurn(e.reason, e.answer, e.durationMs, readsAnswers) : undefined
259 if (line !== undefined) void sayLine(line.text, line.emotion)
260 return next(e)
261 })
262
263 on('command.run', { command: 'face' }, async ($, e) => {
264 const wanted = e.args.trim().toLowerCase()
265 const hide = wanted === 'off' || wanted === 'hide' ? true : wanted === 'on' || wanted === 'show' ? false : !(await read($, isBandHidden))
266 await update($, isBandHidden, () => hide)
267 return { text: hide ? 'M1K3 band hidden.' : 'M1K3 band shown.' }
268 })
269
270 on('command.run', { command: 'companion' }, async ($, e) => {
271 const wanted = e.args.trim().toLowerCase()
272 if (!isAvatar(wanted)) return { text: `Pick one of ${AVATARS.join(', ')} (now: ${await read($, avatar)}).` }
273 await update($, avatar, () => wanted)
274 await update($, isBandHidden, () => false)
275 await $.store.set(AVATAR_STORE_KEY, wanted)
276 lastClip = ''
277 return { text: wanted === 'off' ? 'M1K3 avatar off.' : `M1K3 avatar: ${wanted}.` }
278 })
279
280 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
281 const which = await read($, avatar)
282 if (e.props.hasSurvey || which === 'off' || (await read($, isBandHidden))) {
283 band = undefined
284 return next(e)
285 }
286 band = { requestId: e.requestId, surface: e.surface }
287 const home = root || $.plugin.root
288 const current = await read($, mood)
289 const line = await read($, note)
290 const time = (await $.clock.now()) / 1000
291 const label = statusLabel(current.activity) + (current.activity === 'idle' && current.emotion !== 'neutral' ? ` · ${current.emotion}` : '')
292
293 const { Box, Text } = $.ui.resolve(e)
294 const caption = (
295 <Box flexDirection="column" marginLeft={2}>
296 <Text>
297 <Text dimColor>M1K3 · </Text>
298 <Text bold>{label}</Text>
299 </Text>
300 <Text dimColor wrap="truncate-end">{line}</Text>
301 </Box>
302 )
303
304 if (e.surface === 'terminal') {
305 const { Raster, Image } = $.ui.resolve(e)
306 if (which === 'face') {
307 return (
308 <Box flexDirection="row" alignItems="center">
309 <Raster key="face" columns={FACE_COLUMNS} rows={FACE_ROWS} cells={faceCells(current, time)} />
310 {caption}
311 </Box>
312 )
313 }
314 const look: Look = which
315 const clip = clipFor(current)
316 if (tier === 'image') {
317 return (
318 <Box flexDirection="row" alignItems="center">
319 <Image
320 key="fox"
321 source={{ file: framePath(home, look, clip, 0), format: 'png' }}
322 columns={BAND_COLUMNS}
323 rows={BAND_ROWS}
324 alt={look === 'fox' ? "M1K3's fox" : "M1K3's phosphor fox"}
325 />
326 {caption}
327 </Box>
328 )
329 }
330 const path = braillePath(home, look, clip)
331 const braille = await brailles.get(path, () => $.fs.read(path).then(parseBraille))
332 return (
333 <Box flexDirection="row" alignItems="center">
334 <Raster key="fox" columns={BAND_COLUMNS} rows={BAND_ROWS} cells={brailleCells(braille.band[0] ?? [], BRAILLE_COLOUR[look])} />
335 {caption}
336 </Box>
337 )
338 }
339
340 // Desktop, editor, mobile: no Raster or Image, so the face is an Svg and a
341 // companion is a self-animating SMIL sprite.
342 const { Svg } = $.ui.resolve(e)
343 if (which === 'face') {
344 return (
345 <Box flexDirection="row" alignItems="center">
346 <Svg source={faceSvg(current, time, 8)} alt={`M1K3 ${label}`} width={13 * 8 + 12} height={11 * 8 + 12} />
347 {caption}
348 </Box>
349 )
350 }
351 const look: Look = which
352 const clip = clipFor(current)
353 const manifest = await manifests.get(home, () => $.fs.read(manifestPath(home)).then(parseManifest))
354 const meta = clipMeta(manifest, look, clip)
355 const strip = await strips.get(stripPath(home, look, clip), () => $.fs.read(stripPath(home, look, clip), { as: 'bytes' }).then(bytes => bytes.base64))
356 return (
357 <Box flexDirection="row" alignItems="center">
358 <Svg source={spriteSvg(strip, meta.svgFrames, meta.duration)} alt={`M1K3's ${look}, ${clip.toLowerCase()}`} width={160} height={80} isInteractive />
359 {caption}
360 </Box>
361 )
362 })
363}
364hooks/avatar-state.ts 82 lines1// What M1K3 looks like right now, derived from the session's events, and which
2// avatar draws it. Values live in `$.state` (the contract is types/index.d.ts)
3// so a hot reload and every reader of the band see the same thing.
4//
5// Pure: the transitions take the event and answer the mood to set. The hooks
6// in register.tsx hold the atoms and make the `$` calls (the engine refuses
7// `$` as an argument, and reads an atom only where it is a const of the file).
8//
9// The transitions mirror AvatarState.fromActivity and the CompanionChoreographer's
10// beats (a react one-shot when the answer lands, Sit after a long quiet).
11//
12// Signed: Kev + Claude, 2026-10-05, Confidence 0.8 (pure and pinned by
13// tests/companion.test.ts through the gaits they drive). Prior: Unknown
14
15import { fromActivity, type AvatarState, type Emotion } from './face-math'
16import type { Avatar, Mood } from '../types'
17
18export const AVATARS: readonly Avatar[] = ['face', 'fox', 'phosphor', 'off']
19
20/** How long the happy beat after an answer, and the error face, hold. */
21export const SETTLE_MS = 2500
22export const ERROR_MS = 1500
23/** A quiet half hour drifts the face to sleepy. */
24export const SLEEPY_AFTER_MS = 30 * 60 * 1000
25
26/** A mood to set, with the band's second line, and what follows it after a beat. */
27export type Change = { state: AvatarState; note?: string; then?: { afterMs: number; state: AvatarState; note?: string } }
28
29const idle = (emotion: Emotion = 'neutral'): AvatarState => ({ emotion, activity: 'idle' })
30
31export const onSessionStart = (): Change => ({ state: idle(), note: 'Waiting for you.' })
32
33export const onTurnStart = (text: string): Change => ({ state: fromActivity('thinking'), note: text ? firstWords(text, 48) : 'Thinking.' })
34
35/** Before a tool runs: the generating face, unless the session is idle (a plugin's own call). */
36export const onToolStart = (current: Mood, tool: string, input: Record<string, unknown>): Change | undefined =>
37 current.activity === 'idle' ? undefined : { state: fromActivity('generating'), note: `${tool}${describe(input)}` }
38
39/** After a tool ran: the error face for a beat on a failure, else back to thinking. */
40export function onToolEnd(wasIdle: boolean, isError: boolean, tool: string): Change | undefined {
41 if (isError) return { state: fromActivity('error'), note: `${tool} failed`, then: { afterMs: ERROR_MS, state: fromActivity('thinking') } }
42 return wasIdle ? undefined : { state: fromActivity('thinking') }
43}
44
45/** A permission prompt or an idle prompt: Claude Code is waiting on the person. */
46export function onNotification(kind: string, message: string): Change | undefined {
47 if (kind === 'permission_prompt') return { state: fromActivity('listening'), note: `Needs you: ${firstWords(message, 40)}` }
48 if (kind === 'idle_prompt') return { state: fromActivity('listening'), note: 'Waiting for you.' }
49 return undefined
50}
51
52export function onTurnComplete(reason: string): Change {
53 if (reason === 'answer') return { state: idle('happy'), note: 'Done.', then: { afterMs: SETTLE_MS, state: idle(), note: 'Waiting for you.' } }
54 if (reason === 'error' || reason === 'refusal') {
55 return {
56 state: { emotion: 'angry', activity: 'error' },
57 note: reason === 'refusal' ? 'The model refused.' : 'The turn errored.',
58 then: { afterMs: ERROR_MS, state: idle('sad'), note: 'Waiting for you.' },
59 }
60 }
61 return { state: idle(), note: 'Interrupted.' }
62}
63
64/** The idle tick: sleepy once a quiet half hour has passed, else nothing. */
65export const onQuiet = (current: Mood, now: number, lastActivityAt: number): Change | undefined =>
66 current.activity === 'idle' && current.emotion !== 'sleepy' && now - lastActivityAt >= SLEEPY_AFTER_MS
67 ? { state: idle('sleepy'), note: 'Quiet for a while.' }
68 : undefined
69
70/** Speaking, then back to neutral once the utterance should be over (unless a later mood took over). */
71export const onSpeak = (text: string, forMs: number): Change => ({ state: fromActivity('speaking'), note: text, then: { afterMs: forMs, state: idle() } })
72
73export function firstWords(text: string, max: number): string {
74 const line = text.replace(/\s+/g, ' ').trim()
75 return line.length <= max ? line : `${line.slice(0, max - 1).trimEnd()}…`
76}
77
78function describe(input: Record<string, unknown>): string {
79 const arg = typeof input.command === 'string' ? input.command : typeof input.file_path === 'string' ? input.file_path : typeof input.pattern === 'string' ? input.pattern : ''
80 return arg ? ` ${firstWords(arg, 36)}` : ''
81}
82hooks/companion.ts 83 lines1// The fox companion from baked sprite frames (assets/, rendered from the
2// Khronos Fox GLB: see assets/ATTRIBUTION.md). The gait and clip rules are
3// M1K3Avatar's ClipMapper + CompanionDialect.fox, ported verbatim; crossfades
4// do not exist with sprites, so clips snap and the one-shot beats hide it.
5//
6// Pure: paths, parsing and timing. The reads are the hooks' (register.tsx).
7//
8// Signed: Kev + Claude, 2026-10-05, Confidence 0.8 (the mapping is pinned by
9// tests/companion.test.ts; the image tier's fallback is verify-by-launch).
10// Prior: Kev + claude-opus-4-8 (CompanionSpec.swift, 2026-06-11)
11
12import type { Mood } from '../types'
13
14export type Look = 'fox' | 'phosphor'
15export type Gait = 'rest' | 'alert' | 'move' | 'react' | 'distress' | 'sleepy' | 'affection' | 'fidget'
16export type ClipMeta = { frames: number; duration: number; svgFrames: number }
17export type Manifest = { frameWidth: number; frameHeight: number; looks: Record<Look, Record<string, ClipMeta>> }
18export type BrailleClip = { frames: number; duration: number; band: string[][]; pane: string[][] }
19
20/** The band's slot for a companion, in cells: a 2:1 frame over six rows. */
21export const BAND_COLUMNS = 24
22export const BAND_ROWS = 6
23
24/** The braille colour per look: the phosphor green, or the fox's coat. */
25export const BRAILLE_COLOUR: Record<Look, number> = { fox: 0xe8873a, phosphor: 0x79ffb0 }
26
27/** ClipMapper.gait(for:), with the choreographer's one-shot beats folded in. */
28export function gaitFor(mood: Pick<Mood, 'emotion' | 'activity'>): Gait {
29 switch (mood.activity) {
30 case 'error': return 'distress'
31 case 'listening': case 'thinking': return 'alert'
32 case 'generating': case 'speaking': return 'move'
33 case 'idle':
34 if (mood.emotion === 'sleepy') return 'sleepy'
35 if (mood.emotion === 'happy' || mood.emotion === 'excited' || mood.emotion === 'surprised') return 'react'
36 if (mood.emotion === 'love') return 'affection'
37 return 'rest'
38 }
39}
40
41/** CompanionDialect.fox.clipName(for:): three clips carry eight gaits. */
42export const FOX_CLIPS: Record<Gait, string> = {
43 rest: 'Survey', alert: 'Survey', sleepy: 'Survey', fidget: 'Survey',
44 move: 'Walk',
45 react: 'Run', distress: 'Run', affection: 'Run',
46}
47
48export const clipFor = (mood: Pick<Mood, 'emotion' | 'activity'>): string => FOX_CLIPS[gaitFor(mood)]
49
50/** Which frame of a looping clip shows `elapsed` seconds after it started. */
51export function frameIndex(meta: { frames: number; duration: number }, elapsed: number): number {
52 const phase = ((elapsed % meta.duration) + meta.duration) % meta.duration
53 return Math.min(meta.frames - 1, Math.floor((phase / meta.duration) * meta.frames))
54}
55
56export const manifestPath = (root: string): string => `${root}/assets/manifest.json`
57export const braillePath = (root: string, look: Look, clip: string): string => `${root}/assets/braille/${look}-${clip}.json`
58export const stripPath = (root: string, look: Look, clip: string): string => `${root}/assets/svg/${look}-${clip}.png`
59export const framePath = (root: string, look: Look, clip: string, index: number): string =>
60 `${root}/assets/frames/${look}-${clip}-${String(index).padStart(2, '0')}.png`
61
62export const parseManifest = (text: string): Manifest => JSON.parse(text) as Manifest
63export const parseBraille = (text: string): BrailleClip => JSON.parse(text) as BrailleClip
64
65export function clipMeta(manifest: Manifest, look: Look, clip: string): ClipMeta {
66 const meta = manifest.looks[look]?.[clip]
67 if (meta === undefined) throw new Error(`m1k3: no baked clip ${look}/${clip}`)
68 return meta
69}
70
71/** A once-per-path cache for the asset reads; a reload starts it over, cheaply. */
72export class Loaded<T> {
73 private pending = new Map<string, Promise<T>>()
74 get(key: string, load: () => Promise<T>): Promise<T> {
75 let value = this.pending.get(key)
76 if (value === undefined) {
77 value = load()
78 this.pending.set(key, value)
79 }
80 return value
81 }
82}
83hooks/face-math.ts 160 lines1// The pixel face, ported line for line from macos/Sources/M1K3Avatar
2// (FaceGrid.swift + FaceExpression.swift) so the terminal draws the same face
3// the app does. Pure and deterministic: every phase derives from `time`.
4//
5// Signed: Kev + Claude, 2026-10-05, Confidence 0.85 (a port; tests/face.test.ts
6// pins the same cells FaceExpressionTests does). Prior: Kev + claude-sonnet-4-6
7// (FaceExpression.swift, 2026-06-08)
8
9export type Emotion =
10 | 'neutral' | 'happy' | 'sad' | 'angry' | 'surprised' | 'love' | 'thinking' | 'excited' | 'sleepy'
11export type Activity = 'idle' | 'listening' | 'thinking' | 'generating' | 'speaking' | 'error'
12export type AvatarState = { emotion: Emotion; activity: Activity }
13
14export const COLS = 13
15export const ROWS = 11
16const LEFT_EYE = { col: 4, row: 3 }
17const RIGHT_EYE = { col: 8, row: 3 }
18const MOUTH_ROW = 7
19const MOUTH_COLS = { from: 3, to: 9 }
20const CENTRE_COL = Math.floor(COLS / 2)
21const BACKGROUND = 0.06
22
23/** AvatarActivity.isActive: faster animation while engaged. */
24export const isActive = (activity: Activity): boolean => activity !== 'idle' && activity !== 'error'
25
26/** AvatarState.fromActivity: emotion follows the task. */
27export function fromActivity(activity: Activity): AvatarState {
28 const emotion: Record<Activity, Emotion> = {
29 idle: 'neutral', listening: 'thinking', thinking: 'thinking',
30 generating: 'excited', speaking: 'happy', error: 'angry',
31 }
32 return { emotion: emotion[activity], activity }
33}
34
35/** AvatarActivity.statusLabel(isRecording: false): the badge idiom. */
36export function statusLabel(activity: Activity): string {
37 const names: Record<Activity, string> = {
38 idle: 'Ready', listening: 'Listening…', thinking: 'Thinking…',
39 generating: 'Generating…', speaking: 'Speaking…', error: 'Error',
40 }
41 return names[activity]
42}
43
44/** AvatarEmotion.accentColor (M1K3App/AvatarEmotion+SwiftUI.swift), as 0xRRGGBB. */
45export const ACCENT: Record<Emotion, number> = {
46 neutral: 0x8e8e93, happy: 0x34c759, sad: 0x0a84ff, angry: 0xff453a, surprised: 0xffd60a,
47 love: 0xff375f, thinking: 0xbf5af2, excited: 0xff9900, sleepy: 0x617d8c,
48}
49
50// Swift's `.rounded()` rounds half away from zero; JS Math.round rounds half up.
51const roundAway = (x: number): number => (x < 0 ? -Math.round(-x) : Math.round(x))
52// Swift's truncatingRemainder, for the non-negative times this is fed.
53const mod = (a: number, b: number): number => a - Math.floor(a / b) * b
54
55const cellPhase = (col: number, row: number): number => mod(col * 12.9898 + row * 78.233, 6.2831853)
56
57/** A short blink window (~140 ms every 3.5 s). */
58export const isBlinking = (time: number): boolean => mod(time, 3.5) < 0.14
59
60/** Idle saccade: every 5.3 s the pupils dart one cell sideways for half a second. */
61export function saccadeOffset(time: number): number {
62 const period = 5.3
63 const phase = mod(time, period)
64 if (phase < 2.0 || phase >= 2.5) return 0
65 return Math.floor(time / period) % 2 === 0 ? 1 : -1
66}
67
68function oneEye(col: number, row: number, anchor: { col: number; row: number }, state: AvatarState, time: number): number {
69 if (isBlinking(time) || state.emotion === 'sleepy') {
70 return row === anchor.row && Math.abs(col - anchor.col) <= 1 ? 0.9 : 0
71 }
72 if (state.emotion === 'happy' || state.emotion === 'excited' || state.emotion === 'love') {
73 const onArc = (row === anchor.row && Math.abs(col - anchor.col) === 1) || (row === anchor.row - 1 && col === anchor.col)
74 return onArc ? 1 : 0
75 }
76 if (state.emotion === 'surprised') {
77 const onPlus = (col === anchor.col && Math.abs(row - anchor.row) <= 1) || (row === anchor.row && Math.abs(col - anchor.col) <= 1)
78 return onPlus ? 1 : 0
79 }
80 let pupilCol = anchor.col
81 let pupilRow = anchor.row
82 if (state.activity === 'thinking') {
83 pupilRow -= 1
84 pupilCol += roundAway(Math.sin(time * 1.3))
85 } else if (state.activity === 'idle') {
86 pupilCol += saccadeOffset(time)
87 }
88 return col === pupilCol && row === pupilRow ? 1 : 0
89}
90
91const eyeIntensity = (col: number, row: number, state: AvatarState, time: number): number =>
92 Math.max(oneEye(col, row, LEFT_EYE, state, time), oneEye(col, row, RIGHT_EYE, state, time))
93
94const curlSign = (emotion: Emotion): number =>
95 emotion === 'happy' || emotion === 'excited' || emotion === 'love' ? 1 : emotion === 'sad' || emotion === 'angry' ? -1 : 0
96
97function mouthRows(col: number, state: AvatarState, time: number): number[] {
98 const distance = Math.abs(col - CENTRE_COL)
99 if (state.emotion === 'surprised' && state.activity !== 'speaking') {
100 return distance <= 1 ? [MOUTH_ROW, MOUTH_ROW + 1] : []
101 }
102 const curveRow = MOUTH_ROW - curlSign(state.emotion) * Math.floor(distance / 2)
103 const rows = [curveRow]
104 if (state.activity === 'speaking') {
105 const open = 1 + roundAway(1.5 * (0.5 + 0.5 * Math.sin(time * 9)))
106 for (let extra = 1; extra <= open; extra++) rows.push(curveRow + extra)
107 }
108 return rows
109}
110
111const mouthIntensity = (col: number, row: number, state: AvatarState, time: number): number =>
112 col < MOUTH_COLS.from || col > MOUTH_COLS.to ? 0 : mouthRows(col, state, time).includes(row) ? 1 : 0
113
114/** Horizontal CRT-style tear while erroring, in cell units; zero outside `.error`. */
115export function columnShift(row: number, state: AvatarState, time: number): number {
116 if (state.activity !== 'error') return 0
117 const sweep = 0.9
118 const phase = mod(time, sweep) / sweep
119 const tearRow = Math.floor(phase * ROWS)
120 if (row === tearRow) return 0.55
121 if (row === tearRow + 1) return -0.35
122 return 0
123}
124
125/** Brightness 0…1 for a cell. */
126export function intensity(col: number, row: number, state: AvatarState, time: number): number {
127 const feature = Math.max(eyeIntensity(col, row, state, time), mouthIntensity(col, row, state, time))
128 let base = Math.max(BACKGROUND, feature)
129 if (columnShift(row, state, time) !== 0) base = Math.max(base, 0.3)
130 const flicker = 1 + 0.06 * Math.sin(time * 7 + cellPhase(col, row))
131 return Math.min(Math.max(base * flicker, 0), 1)
132}
133
134/**
135 * The whole 13×11 grid for one instant, row-major. A terminal shifts whole
136 * cells, so the tear is rounded (one row jumps a cell, the next only glows).
137 */
138export function frame(state: AvatarState, time: number): number[][] {
139 const grid: number[][] = []
140 for (let row = 0; row < ROWS; row++) {
141 const shift = roundAway(columnShift(row, state, time))
142 const torn = columnShift(row, state, time) !== 0
143 const cells: number[] = []
144 for (let col = 0; col < COLS; col++) {
145 const source = col - shift
146 cells.push(source >= 0 && source < COLS ? intensity(source, row, state, time) : torn ? 0.3 : BACKGROUND)
147 }
148 grid.push(cells)
149 }
150 return grid
151}
152
153/** `accent` scaled by `level` over black, as 0xRRGGBB. */
154export function shade(accent: number, level: number): number {
155 const r = Math.round(((accent >> 16) & 0xff) * level)
156 const g = Math.round(((accent >> 8) & 0xff) * level)
157 const b = Math.round((accent & 0xff) * level)
158 return (r << 16) | (g << 8) | b
159}
160hooks/guard.ts 141 lines1// CLAUDE.md's "nevers" as enforced rules. A `tool.call` hook that returns
2// `{ deny }` stops the call before the permission check; the reason reaches the
3// model as the tool's error, so it can choose the right thing instead.
4//
5// Denies are the explicit nevers. Rules that need judgement (a master merge
6// that resolves a conflict, a stack base) get a toast, not a deny. Every rule
7// reads the command with its quoted spans and heredoc bodies blanked, so a
8// commit message or a PR body that merely mentions a phrase never trips it;
9// the one rule that lives inside quotes by nature (the AppleScript quit) reads
10// the raw command and needs `osascript` beside it. Best effort by design: a
11// `tee`, `cp` or `sed -i` onto the session memory is not caught, nor a push
12// whose refspec comes after a later flag (`git push origin HEAD --force master`),
13// nor a command wrapped in `bash -c`, `eval`, `git -C` or `git -c`, nor a
14// redirect onto an expansion (`> "$PWD/.claude/project-memory.md"`): a quoted
15// word with `$` in it is prose to the rules, since its value is unknown here.
16// The file tools' path is matched as spelled: a `..` hop or a symlink onto the
17// memory is not resolved (`$.fs.stat(path, { resolve: true })` could, later).
18//
19// Signed: Kev + Claude, 2026-10-05, Confidence 0.85 (every rule is pinned by
20// tests/guard.test.ts, denies, allows and the prose cases alike). Prior: Unknown
21// Review: Kev + Claude, 2026-10-05 — summoned pass on #493: rules anchored to a
22// command boundary and read off the blanked command (quoted mentions, heredocs,
23// `main-menu`, `merge-base` no longer match); `+master`, `refs/heads/master` and
24// `git add <file> -f` now do. Confidence 0.85.
25// Review: Kev + Claude, 2026-10-05 — second pass: a newline is a command boundary
26// too (multi-line Bash is how a push to master would most likely slip through).
27// Review: Kev + Claude, 2026-10-05 — auto pass on the Swift head: a quoted path or
28// refspec is kept bare, not blanked (`> ".claude/project-memory.md"`, `"master"`);
29// the file tools' pattern ends at the name, like the Bash one.
30// Review: Kev + Claude, 2026-10-05 — second auto pass: `>>` with no space
31// (`cat >>.claude/project-memory.md`) is the sanctioned append, not a replace.
32
33import type { On } from 'claude-code'
34
35export type Rule = { test: RegExp; reason: string; raw?: true }
36
37// The session memory, twice on purpose: a tool's `file_path` is the whole path
38// (so `$`), a Bash command carries it as one word among others (so a lookahead).
39// Either way `project-memory.md.bak` is not it.
40const MEMORY = /\.claude\/project-memory\.md$/
41const MEMORY_PATH = String.raw`\.claude\/project-memory\.md(?=\s|$)`
42/** Where a command may start: a line, or after a separator, with the usual wrappers. */
43const AT_START = String.raw`(?:^|[;&|(\n]|\|\||&&)\s*(?:sudo\s+|time\s+|env\s+(?:\S+=\S*\s+)*|[A-Z_]+=\S*\s+)*`
44
45export const BASH_DENIES: readonly Rule[] = [
46 {
47 test: new RegExp(`${AT_START}git\\s+push\\b(?:\\s+-\\S+)*\\s+\\S+\\s+\\+?(?:\\S+:)?(?:refs\\/heads\\/)?(?:master|main)(?=\\s|$)`),
48 reason: 'never a direct push to master: push the branch and land it with macos/tools/ci/land.sh',
49 },
50 {
51 test: new RegExp(`${AT_START}git\\s+add\\b(?=[^\\n;&|]*\\s(?:-\\S*f\\S*|--force)(?=\\s|$))(?=[^\\n;&|]*${MEMORY_PATH})`),
52 reason: '.claude/project-memory.md is gitignored on purpose and never force-added',
53 },
54 {
55 test: new RegExp(String.raw`(?:^|[^>])>(?!>)[|&]?\s*\S*${MEMORY_PATH}`),
56 reason: '.claude/project-memory.md is append-only: use >> to add a block, never > to replace it',
57 },
58 {
59 test: new RegExp(`${AT_START}(?:hf|huggingface-cli)\\s+download(?=\\s|$)`),
60 reason: 'never pre-seed the model cache with hf download (cache poison); let the app fetch and verify weights',
61 },
62 {
63 test: new RegExp(`${AT_START}defaults\\s+write\\s+app\\.m1k3(?=\\s|$)`),
64 reason: 'defaults write app.m1k3 never reaches the sandboxed app on macOS 27: pass A/B overrides as argv (M1K3 -prefillStepSize 512)',
65 },
66 {
67 // Inside quotes by nature, so read raw; `osascript` beside it is what makes it a run, not a mention.
68 test: /\bosascript\b[\s\S]*tell\s+application\s+id\s+"app\.m1k3"/,
69 reason: 'tell application id "app.m1k3" quits the live app too: stop a worktree build by PID',
70 raw: true,
71 },
72]
73
74export const BASH_WARNINGS: readonly Rule[] = [
75 {
76 test: new RegExp(`${AT_START}git\\s+merge(?=\\s|$)[^\\n;&|]*\\s(?:origin\\/)?(?:master|main)(?=\\s|$)`),
77 reason: 'merging master into a PR branch costs a full CI + review cycle: only for a conflict or a fix the PR needs',
78 },
79 {
80 test: new RegExp(`${AT_START}gh\\s+pr\\s+merge\\b[^\\n;&|]*\\s--delete-branch(?=\\s|$)`),
81 reason: '--delete-branch on a stack base closes its dependants: retarget them first (gh pr edit <N> --base master)',
82 },
83 {
84 test: new RegExp(`${AT_START}git\\s+push\\b[^\\n;&|]*\\s(?:--force|-f)(?=\\s|$)`),
85 reason: 'a plain --force rewrites history for everyone on the branch: --force-with-lease, and never on someone else\'s branch',
86 },
87]
88
89/**
90 * The command with its quoted spans and heredoc bodies blanked, so the rules
91 * read what runs and never the prose it carries (a commit message, a PR body,
92 * a grep pattern, a CLAUDE.md edit).
93 */
94export function bareCommand(command: string): string {
95 return command
96 // A heredoc body: from the line after `<<WORD` (quoted or not, `<<-` too) to the line holding WORD.
97 .replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n[\s\S]*?\n[ \t]*\2(?=\s|$)/g, '<<HEREDOC')
98 // A quoted word stays (a quoted path or refspec is a normal shell habit); quoted prose is blanked.
99 .replace(/"((?:[^"\\]|\\[\s\S])*)"/g, (_, inner: string) => (isBareWord(inner) ? inner : '""'))
100 .replace(/'([^']*)'/g, (_, inner: string) => (isBareWord(inner) ? inner : "''"))
101}
102
103/** One argument with nothing the shell would read: no whitespace, quotes, expansions or operators. */
104const isBareWord = (text: string): boolean => text.length > 0 && !/[\s"'`$;&|<>()\\]/.test(text)
105
106export function denyFor(command: string, rules: readonly Rule[] = BASH_DENIES): string | undefined {
107 const bare = bareCommand(command)
108 return rules.find(rule => rule.test.test(rule.raw ? command : bare))?.reason
109}
110
111export const MEMORY_DENY = '.claude/project-memory.md is append-only; add a block with `cat >> .claude/project-memory.md`'
112
113/** The matched `tool.call` hooks: Bash commands, and the two file tools on the session memory. */
114export function registerGuard(on: On): void {
115 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
116 const command = e.command.trim()
117 const reason = denyFor(command)
118 if (reason !== undefined) return { deny: `${$.plugin.name} guard: ${reason}` }
119 const warning = denyFor(command, BASH_WARNINGS)
120 if (warning !== undefined) $.ui.toast(`${$.plugin.name} guard: ${warning}`, { timeoutMs: 8000 })
121 return next(e)
122 })
123
124 // A `Write` replaces the whole file: that is how 700 lines were lost on
125 // 2026-09-15. `Edit` can drop a block just as silently, so it is refused too
126 // (a typo in the chronicle stays; the chronicle is append-only). Appending
127 // goes through the shell with >>.
128 on('tool.call', { tool: 'Write' }, ($, e, next) => (MEMORY.test(e.file_path) ? { deny: `${$.plugin.name} guard: ${MEMORY_DENY}` } : next(e)))
129 on('tool.call', { tool: 'Edit' }, ($, e, next) => (MEMORY.test(e.file_path) ? { deny: `${$.plugin.name} guard: ${MEMORY_DENY}` } : next(e)))
130}
131
132/**
133 * `xcodegen` after every checkout: the project file is a gitignored artifact.
134 * `undefined` when it is current (or this is not the M1K3 checkout).
135 */
136export function xcodeprojMessage(hasSpec: boolean, hasProject: boolean, specMtimeMs: number, projectMtimeMs: number): string | undefined {
137 if (!hasSpec) return undefined
138 if (!hasProject) return 'xcodegen: M1K3.xcodeproj is missing (run xcodegen in macos/)'
139 return specMtimeMs > projectMtimeMs ? 'xcodegen: project.yml is newer than M1K3.xcodeproj (run xcodegen in macos/)' : undefined
140}
141hooks/raster.ts 103 lines1// Packing for the terminal's `Raster` leaf: `columns * rows` little-endian u32
2// triplets [codePoint, foreground, background], standard padded base64. The
3// module environment has no Buffer and may lack `Uint8Array.toBase64`, so the
4// encoder lives here.
5//
6// Signed: Kev + Claude, 2026-10-05, Confidence 0.8 (the packing is pinned by
7// tests/face.test.ts; how a terminal paints it is verify-by-launch). Prior: Unknown
8
9import { ACCENT, COLS, ROWS, frame, shade, type AvatarState } from './face-math'
10
11/** The terminal's default colour: bit 24 alone. */
12export const DEFAULT_COLOUR = 0x01000000
13const UPPER_HALF = 0x2580 // ▀: foreground paints the top half, background the bottom
14
15const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
16
17export function toBase64(bytes: Uint8Array): string {
18 let out = ''
19 for (let i = 0; i < bytes.length; i += 3) {
20 const a = bytes[i] ?? 0, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
21 const hasB = i + 1 < bytes.length, hasC = i + 2 < bytes.length
22 const n = (a << 16) | (b << 8) | c
23 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63) + (hasB ? B64.charAt((n >> 6) & 63) : '=') + (hasC ? B64.charAt(n & 63) : '=')
24 }
25 return out
26}
27
28export type Cell = { glyph: number; fg: number; bg: number }
29
30/** Encodes cells (row-major) as `RasterProps.cells`. */
31export function packCells(cells: readonly Cell[]): string {
32 const bytes = new Uint8Array(cells.length * 12)
33 const view = new DataView(bytes.buffer)
34 cells.forEach((cell, i) => {
35 view.setUint32(i * 12, cell.glyph, true)
36 view.setUint32(i * 12 + 4, cell.fg, true)
37 view.setUint32(i * 12 + 8, cell.bg, true)
38 })
39 return toBase64(bytes)
40}
41
42/** The face's box in terminal cells: two LED rows per cell. */
43export const FACE_COLUMNS = COLS
44export const FACE_ROWS = Math.ceil(ROWS / 2)
45
46/** One face frame as packed `Raster` cells: `▀` with the top LED as foreground, the bottom as background. */
47export function faceCells(state: AvatarState, time: number): string {
48 const grid = frame(state, time)
49 const accent = ACCENT[state.emotion]
50 const cells: Cell[] = []
51 for (let r = 0; r < FACE_ROWS; r++) {
52 for (let c = 0; c < COLS; c++) {
53 const top = grid[2 * r]?.[c] ?? 0
54 const bottom = grid[2 * r + 1]?.[c] ?? 0
55 cells.push({ glyph: UPPER_HALF, fg: shade(accent, top), bg: shade(accent, bottom) })
56 }
57 }
58 return packCells(cells)
59}
60
61/** Braille rows (one string per row, one glyph per cell) as packed cells in one colour. */
62export function brailleCells(rows: readonly string[], colour: number): string {
63 const cells: Cell[] = []
64 for (const row of rows) {
65 for (const glyph of row) {
66 const code = glyph.codePointAt(0) ?? 0x20
67 cells.push({ glyph: code, fg: colour, bg: DEFAULT_COLOUR })
68 }
69 }
70 return packCells(cells)
71}
72
73/** The face as SVG markup for the desktop and editor surfaces, which have no `Raster`. */
74export function faceSvg(state: AvatarState, time: number, size = 12): string {
75 const grid = frame(state, time)
76 const accent = ACCENT[state.emotion]
77 const hex = `#${accent.toString(16).padStart(6, '0')}`
78 const pad = 6
79 const w = pad * 2 + COLS * size, h = pad * 2 + ROWS * size
80 let rects = ''
81 for (let r = 0; r < ROWS; r++) {
82 for (let c = 0; c < COLS; c++) {
83 const level = grid[r]?.[c] ?? 0
84 const x = pad + c * size + 2, y = pad + r * size + 2
85 rects += `<rect x="${x}" y="${y}" width="${size - 4}" height="${size - 4}" rx="1.5" fill="${hex}" fill-opacity="${(0.08 + 0.92 * level).toFixed(3)}"/>`
86 }
87 }
88 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#0e1013" rx="6"/>${rects}</svg>`
89}
90
91/**
92 * A baked sprite strip as a self-animating SVG: an `<image>` stepped along x
93 * by an SMIL animate. Plays itself, so the desktop gets no redraw traffic.
94 */
95export function spriteSvg(pngBase64: string, frames: number, durationSeconds: number, frameWidth = 80, frameHeight = 40): string {
96 const values = Array.from({ length: frames }, (_, i) => -i * frameWidth).join(';')
97 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${frameWidth} ${frameHeight}" width="${frameWidth * 3}" height="${frameHeight * 3}">`
98 + `<rect width="${frameWidth}" height="${frameHeight}" fill="#0e1013" rx="4"/>`
99 + `<image href="data:image/png;base64,${pngBase64}" x="0" y="0" width="${frameWidth * frames}" height="${frameHeight}">`
100 + `<animate attributeName="x" calcMode="discrete" values="${values}" dur="${durationSeconds.toFixed(2)}s" repeatCount="indefinite"/>`
101 + `</image></svg>`
102}
103hooks/voice.ts 87 lines1// M1K3 as Claude Code's voice: when a session needs the person, or finishes a
2// long turn, the live app says so through its own MCP server (`speak`), over
3// the connection `m1k3 connect claude` set up. With the app not running, or
4// the server not connected, the line becomes a toast instead (register.tsx).
5//
6// Pure: what to say and for how long. The call itself is the hook's.
7//
8// Signed: Kev + Claude, 2026-10-05, Confidence 0.75 (the lines are pinned; what
9// the app does with them rides its own `speak` tool). Prior: Unknown
10// Review: Kev + Claude, 2026-10-05 — summoned pass on #493: a finished turn says
11// "Done after N seconds." alone unless `readAnswers` is on (the first sentence
12// read aloud on an open-plan Mac is the person's call); the call gets a bound
13// and a gap (SPEAK_TIMEOUT_MS, SPEAK_GAP_MS) in register.tsx. Confidence 0.8.
14// Review: Kev + Claude, 2026-10-05 — auto pass: snake_case survives the markdown
15// strip; an empty line is 0 ms of speech.
16// Review: Kev + claude-opus-5-5, 2026-10-06 — Kev's live run: every line toasted.
17// The debug log showed the hook's `speak` through the permission check, and auto
18// mode's classifier, unavailable between turns, failing it closed. SPEAK_TOOL and
19// SPEAK_PERMISSION_HINT name the rule that fixes it. Confidence 0.85.
20
21import { firstWords } from './avatar-state'
22
23/** The server's name as /mcp lists it (M1K3CLICore/ConnectPlan.serverName). */
24export const SERVER = 'm1k3'
25/** `speak` as the permission check names it. */
26export const SPEAK_TOOL = `mcp__${SERVER}__speak`
27/** Said once per load when the permission check would ask before `speak` (no rule allows it). */
28export const SPEAK_PERMISSION_HINT = `M1K3 can't speak here: add "${SPEAK_TOOL}" to permissions.allow in ~/.claude/settings.json`
29/** Turns shorter than this end quietly; the band already shows them. */
30export const LONG_TURN_MS = 20_000
31/** A `speak` that has not answered by then is given up on (the line toasts instead). */
32export const SPEAK_TIMEOUT_MS = 8_000
33/** Lines closer together than this toast rather than queue speech behind speech. */
34export const SPEAK_GAP_MS = 10_000
35
36export type Line = { text: string; emotion: string }
37
38export type VoiceEvent =
39 | { kind: 'permission'; message: string }
40 | { kind: 'idle' }
41 | { kind: 'done'; answer: string; seconds: number; readAnswer?: boolean }
42 | { kind: 'failed'; reason: string }
43
44export function lineFor(event: VoiceEvent): string {
45 switch (event.kind) {
46 case 'permission': return `Claude Code needs you: ${firstWords(event.message, 80)}`
47 case 'idle': return 'Claude Code is waiting for you.'
48 case 'done': {
49 const done = `Done after ${Math.round(event.seconds)} seconds.`
50 return event.readAnswer ? `${done} ${firstSentence(event.answer)}`.trim() : done
51 }
52 case 'failed': return event.reason === 'refusal' ? 'Claude Code stopped: the model refused.' : 'Claude Code stopped on an error.'
53 }
54}
55
56export function firstSentence(text: string): string {
57 // Markdown marks go; an underscore inside a word (snake_case) is part of it.
58 const plain = text.replace(/[`*#>]/g, '').replace(/(^|\s)_+|_+(?=\s|$)/g, '$1').replace(/\s+/g, ' ').trim()
59 const end = plain.search(/[.!?](\s|$)/)
60 return firstWords(end > 0 ? plain.slice(0, end + 1) : plain, 140)
61}
62
63/** Roughly how long the app takes to say it, so the band's mouth moves meanwhile. */
64export const speakingMs = (text: string): number => {
65 const words = text.trim().split(/\s+/).filter(Boolean).length
66 return words === 0 ? 0 : Math.min(8000, 300 + words * 380)
67}
68
69/** What to say for a notification, or nothing. */
70export function voiceForNotification(kind: string, message: string): Line | undefined {
71 if (kind === 'permission_prompt') return { text: lineFor({ kind: 'permission', message }), emotion: 'thinking' }
72 if (kind === 'idle_prompt') return { text: lineFor({ kind: 'idle' }), emotion: 'neutral' }
73 return undefined
74}
75
76/** What to say when a turn ends, or nothing: long answers and failures only. */
77export function voiceForTurn(reason: string, answer: string, durationMs: number, readAnswer = false): Line | undefined {
78 if (reason === 'answer') {
79 return durationMs >= LONG_TURN_MS ? { text: lineFor({ kind: 'done', answer, seconds: durationMs / 1000, readAnswer }), emotion: 'happy' } : undefined
80 }
81 if (reason === 'error' || reason === 'refusal') return { text: lineFor({ kind: 'failed', reason }), emotion: 'sad' }
82 return undefined
83}
84
85/** Whether a line may be spoken now, given when the last one was. */
86export const maySpeak = (now: number, lastSpokeAt: number): boolean => now - lastSpokeAt >= SPEAK_GAP_MS
87types/index.d.ts 24 lines1// The plugin's `$.state` contract: every value the m1k3 mod keeps in the
2// session, declared under its name. `claude plugin validate` holds the module
3// to it.
4
5export type Avatar = 'face' | 'fox' | 'phosphor' | 'off'
6
7export type Mood = {
8 emotion: 'neutral' | 'happy' | 'sad' | 'angry' | 'surprised' | 'love' | 'thinking' | 'excited' | 'sleepy'
9 activity: 'idle' | 'listening' | 'thinking' | 'generating' | 'speaking' | 'error'
10 /** When this mood was set, ms since the epoch; a settle timer checks it before overwriting. */
11 since: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 m1k3: {
17 avatar: Avatar
18 mood: Mood
19 isBandHidden: boolean
20 note: string
21 }
22 }
23}
24