SLOPSHOPPER

m1k3

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…

newbandguardcommandtoaststatus
★ 4v0.1.0NOASSERTIONupdated 2026-10-09Round-Tower/M1K3/.claude/skills/m1k3
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · m1k3
› 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(rm -rf build && git push --force origin main) ⎿ Denied by m1k3: m1k3 guard: never a direct push to master: push the branch and land it with macos/tools/ci ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /face ⎿ m1k3: M1K3 band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

m1k3 — the M1K3 mod for Claude Code

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:

PartWhat it doesWhere
guardCLAUDE.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
voiceWhen 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 bandThe 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

What the guard does not catch

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.

Commands

  • /face hides or shows the band (/face off, /face on).
  • /companion face|fox|phosphor|off picks the avatar; the pick is kept across sessions.

How the avatar reaches each surface

SurfaceFaceCompanion
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 idleImage by file path, one PNG per frame, swapped by $.ui.blit at the clip's rate; the terminal reads the file itself
Any other terminalthe same Rastera 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, mobileSvg of rounded rects, redrawn up to 4 times a second while activeSvg 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

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.

Developing

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.

Source 8 files
hooks/register.tsx 364 lines
1// 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}
364
hooks/avatar-state.ts 82 lines
1// 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}
82
hooks/companion.ts 83 lines
1// 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}
83
hooks/face-math.ts 160 lines
1// 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}
160
hooks/guard.ts 141 lines
1// 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}
141
hooks/raster.ts 103 lines
1// 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}
103
hooks/voice.ts 87 lines
1// 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
87
types/index.d.ts 24 lines
1// 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