SLOPSHOPPER

clawd-hud-spike

Spike da HUD RPG: sondas no app real para os itens A VALIDAR (sem gastar tokens)

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-hud-spike
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spike ⎿ clawd-hud-spike: Laboratório aberto. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clawd-hud-spike

Um mod mínimo que testa no app real o que os testes do motor não alcançam (os itens "A VALIDAR" do desenho), antes da Fase 1. Não gasta tokens: não chama $.model, agent.spawn, compact nem prompt.submit.

Ele foi escrito e testado contra os tipos da build 2.1.295. Os testes cobrem as superfícies terminal e desktop no motor. Só não sabem se o desktop de verdade desenha como esperado, e é para isso que serve o roteiro abaixo.

O que ele mexe

  • Pane "Laboratório" (/spike): uma linha por sonda, com ✓ ok e ✗ falhou. Os vereditos ficam no $.store.
  • Linhas de ferramenta no chat: ganham o Clawd de 40 px à esquerda.
  • Faixa acima do prompt: mostra o Clawd de 90 px enquanto o Claude trabalha.
  • Proteção: só para spike-sandbox/.env* e spike-sandbox/*pipelines*.yml. Antes de editar um desses arquivos, abre o diálogo do Claude com a linha de arquivos por cima. Se der erro, bloqueia.

Roteiro no app desktop (Windows)

  1. Confira a versão do Claude Code do app. Se for anterior à 2.1.295, rode antes claude plugin validate C:\Projetos\Claude-code-hero-journey\mods\clawd-hud-spike num terminal.
  2. Atualize o repositório: git pull em C:\Projetos\Claude-code-hero-journey.
  3. Em %USERPROFILE%\.claude\settings.json, acrescente ao bloco "env": ``json "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Projetos\\Claude-code-hero-journey\\mods\\clawd-hud-spike", "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" ` Depois, feche e abra o app. A pasta dev-mods` fica intocada.
  4. Na aba Code, abra uma sessão neste repositório e digite /spike.
  5. Percorra o Laboratório (‹ › mudam a página):
ItemO que fazerO que observar
1Olhe os dois Clawds no topo e pare o mouse sobre elesPiscam (CSS), as estrelas pulsam (SMIL), a engrenagem gira, o tooltip aparece
2Troque o tema em /config e depois o claro/escuro do appAs cores dos Clawds mudam? O "tema lido" muda?
3Testar diálogoA linha com ícones e .env.homologacao +1 · azure-pipelines.yml +3 −1 aparece acima do diálogo? As opções têm descrição?
3bPeça ao Claude: "mude API_URL em spike-sandbox/.env.teste para https://outro.invalid"Aparece o diálogo com a linha? O diálogo nativo de permissão aparece também? Recusar bloqueia?
4Levar ao promptO texto aparece na caixa de digitação? Qual é o resultado gravado?
6Mande qualquer pergunta e espere a respostaO motivo e o começo da resposta aparecem no item 6
7Pagine com ‹ ›O rodapé "Mochila · Mana · Vigor" fica parado embaixo? Os números batem com o uso real?
8Peça ao Claude para ler um arquivo; depois clique em Recolher leituras e peça outra leituraO Clawd aparece à esquerda das linhas? Recolhida, a leitura some sem deixar uma linha vazia?
9/10ListarQuais ferramentas do app aparecem (mcp__ccd_*, sessão, diff)?
11Repare se o Laboratório abriu sozinho ao começar a sessãoVeja a largura medida e se "docka"
12Abra uma segunda sessão e rode /spikeO número de sessões subiu?
+Mande o Claude rodar algo demoradoA faixa com o Clawd de 90 px aparece enquanto ele trabalha e some no fim?

O item 5 (Client como botão estilizado) foi descartado pelos tipos: o Client não aceita Svg dentro.

  1. Rode /spike-relatorio e cole a saída na conversa do /plan. Acrescente o que viu e que o relatório não captura, por exemplo se o diálogo nativo apareceu em dobro.

Desligar

Remova as duas chaves do bloco "env" e reinicie o app.

Desenvolvimento

claude plugin validate mods/clawd-hud-spike
claude plugin test mods/clawd-hud-spike

O código que usa $ fica todo em hooks/register.tsx, porque o motor não segue o $ através de um import. art.ts, guard.ts e lab.ts são funções puras: a arte do Clawd, os padrões de proteção e o catálogo das sondas.

Source 5 files
hooks/register.tsx 371 lines
1// clawd-hud-spike: sondas no app real para os itens A VALIDAR do handoff.
2// Nenhum hook chama o modelo: nada de $.model, agent.spawn, compact ou submit.
3// Tudo o que usa `$` mora neste arquivo: o motor não segue o `$` por um import.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
7
8import type { GuardFile, Placement, ProbeId, Verdict } from '../types'
9import { CLASS_NAME, classColor, classOf, crab, guardLine, paletteOf } from './art'
10import type { Crab } from './art'
11import { deltaOf, GUARD_HINT, GUARD_OPTIONS, PROTECTED } from './guard'
12import { footerText, measuredLine, PROBES, reportText } from './lab'
13import type { Snapshot } from './lab'
14
15const PANE = 'clawd-spike'
16
17const verdicts = atom({ plugin: 'clawd-hud-spike', key: 'verdicts' } as const, {})
18const measures = atom({ plugin: 'clawd-hud-spike', key: 'measures' } as const, {})
19const guard = atom({ plugin: 'clawd-hud-spike', key: 'guard' } as const, [])
20const working = atom({ plugin: 'clawd-hud-spike', key: 'working' } as const, null)
21const turn = atom({ plugin: 'clawd-hud-spike', key: 'turn' } as const, null)
22const placement = atom({ plugin: 'clawd-hud-spike', key: 'placement' } as const, {})
23const collapseReads = atom({ plugin: 'clawd-hud-spike', key: 'collapseReads' } as const, false)
24const page = atom({ plugin: 'clawd-hud-spike', key: 'page' } as const, 0)
25
26const READS = /^(Read|Grep|Glob|LS)$/
27const ROWS_PER_PROBE = 3
28const FIXED_ROWS = 9
29
30/** O que o render do Pane viu, levado pelos botões até o estado. */
31type Seen = { surface: string; placement: Placement }
32
33type Ui = ReturnType<EngineInterface['ui']['resolve']>
34type DesktopUi = Extract<Ui, { Svg: unknown }>
35
36/** Svg só existe na tabela do desktop; quem chama confere a superfície antes. */
37const svgOf = (ui: Ui, c: Crab) => {
38  const { Svg } = ui as DesktopUi
39  return <Svg key={c.alt} source={c.source} width={c.width} height={c.height} alt={c.alt} isInteractive />
40}
41
42const summaryOf = (e: { tool: string } & Record<string, unknown>) => {
43  for (const field of ['description', 'file_path', 'pattern', 'url', 'query', 'command'] as const) {
44    const value = e[field]
45    if (typeof value === 'string' && value !== '') return value.length > 90 ? `${value.slice(0, 89)}…` : value
46  }
47  return e.tool
48}
49
50async function refreshTheme($: EngineInterface) {
51  const row = (await $.config.list()).find(r => r.key === 'theme')
52  const theme = row === undefined ? 'sem linha "theme"' : String(row.value)
53  await update($, measures, m => ({ ...m, theme }))
54  return theme
55}
56
57async function snapshot($: EngineInterface): Promise<Snapshot> {
58  return {
59    measures: await read($, measures),
60    turn: await read($, turn),
61    collapseReads: await read($, collapseReads),
62    placement: await read($, placement),
63  }
64}
65
66/** Grava o veredito e, junto, o que o render do Pane mediu (o render não escreve estado). */
67async function setVerdict($: EngineInterface, id: ProbeId, verdict: Verdict, seen: Seen) {
68  const all = await update($, verdicts, marks => ({ ...marks, [id]: verdict }))
69  await $.store.set('verdicts', all)
70  await update($, placement, p => ({ ...p, ...seen.placement }))
71  await update($, measures, m => ({ ...m, surface: seen.surface }))
72}
73
74/** Proteção pelo diálogo do Claude: a linha de arquivos vive no estado enquanto ele pergunta. */
75async function askWithGuard($: EngineInterface, files: readonly GuardFile[]) {
76  await update($, guard, () => [...files])
77  try {
78    const names = files.map(f => f.name).join(' e ')
79    return await $.ui.ask(`O Claude quer alterar ${names}, que são protegidos. O que fazer?`, {
80      header: 'Proteção',
81      options: GUARD_OPTIONS,
82    })
83  } catch {
84    return 'dispensado'
85  } finally {
86    await update($, guard, () => [])
87  }
88}
89
90async function guardEdit($: EngineInterface, tool: string, path: string, delta: { added: number; removed: number }) {
91  const name = path.split(/[\\/]/).pop() ?? path
92  const answer = await askWithGuard($, [{ name, ...delta }])
93  await update($, measures, m => ({ ...m, ask: `${answer} (via ${tool})` }))
94  if (answer === 'Aprovar') return undefined
95  if (answer === 'Editar no chat') {
96    return { deny: `${name} é protegido: a pessoa quer ajustar a mudança no chat antes. Pergunte o que mudar.` }
97  }
98  return { deny: `${name} é protegido e a alteração foi recusada (${answer}).` }
99}
100
101/** Ações das sondas que precisam de um clique. Nenhuma gasta tokens. */
102async function act($: EngineInterface, id: ProbeId) {
103  if (id === 'ask') {
104    const answer = await askWithGuard($, [
105      { name: '.env.homologacao', added: 1, removed: 0 },
106      { name: 'azure-pipelines.yml', added: 3, removed: 1 },
107    ])
108    await update($, measures, m => ({ ...m, ask: answer }))
109  } else if (id === 'fill') {
110    const r = await $.prompt.fill({ text: 'Teste da sonda 4: texto vindo do clawd-hud-spike' })
111    const fill = r.isFilled ? 'preencheu' : `recusou: ${r.refusal ?? 'sem causa (um hook)'}`
112    await update($, measures, m => ({ ...m, fill }))
113  } else if (id === 'tooluse') {
114    await update($, collapseReads, isOn => !isOn)
115  } else if (id === 'ferramentas') {
116    const all = await $.tool.list()
117    const hits = all.map(t => t.name).filter(n => /^mcp__ccd|session|handoff|diff|preview/i.test(n))
118    const tools = `${all.length} ferramentas; candidatas: ${hits.length === 0 ? 'nenhuma' : hits.slice(0, 15).join(', ')}`
119    await update($, measures, m => ({ ...m, tools }))
120  }
121}
122
123async function renderPane($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement> {
124  const seen: Seen = {
125    surface: e.surface,
126    placement: {
127      placement: e.props.placement,
128      columns: e.viewport?.columns,
129      rows: e.viewport?.rows,
130      isFullscreen: e.viewport?.isFullscreen,
131      bodyRows: e.props.scroll.bodyRows,
132      bodyColumns: e.props.bodyColumns,
133    },
134  }
135  const stored = await snapshot($)
136  const snap = { ...stored, placement: { ...stored.placement, ...seen.placement } }
137  const palette = paletteOf(snap.measures.theme)
138  const marks = await read($, verdicts)
139  const perPage = Math.max(1, Math.floor((e.props.scroll.bodyRows - FIXED_ROWS) / ROWS_PER_PROBE))
140  const pages = Math.ceil(PROBES.length / perPage)
141  const at = Math.min(await read($, page), pages - 1)
142  const shown = PROBES.slice(at * perPage, at * perPage + perPage)
143  const footer = footerText(await $.session.usage())
144
145  const ui = $.ui.resolve(e)
146  const { Box, Text, Button } = ui
147  const markOf = (id: ProbeId) => (marks[id] === 'ok' ? '✓' : marks[id] === 'falhou' ? '✗' : '·')
148  const colorOf = (id: ProbeId) => (marks[id] === 'ok' ? 'success' : marks[id] === 'falhou' ? 'error' : 'text')
149
150  return (
151    <Box flexDirection="column">
152      {e.surface === 'desktop' ? (
153        <Box key="art" flexDirection="row" gap={2} justifyContent="center">
154          {svgOf(ui, crab('druida', 56, palette, { tip: 'Clawd druida: pisca (CSS) e as estrelas pulsam (SMIL)' }))}
155          {svgOf(ui, crab('artifice', 90, palette, { withProp: true, tip: 'Artífice: a engrenagem gira (CSS)' }))}
156        </Box>
157      ) : (
158        <Text key="art" dimColor>
159          (Svg só no desktop: aqui é {e.surface})
160        </Text>
161      )}
162      <Text bold>
163        Laboratório do spike · página {at + 1} de {pages}
164      </Text>
165      <Box flexDirection="column" flexGrow={1}>
166        {shown.map(probe => {
167          const detail = measuredLine(snap, probe.id)
168          return (
169            <Box key={`row-${probe.id}`} flexDirection="column">
170              <Box flexDirection="row" gap={1}>
171                <Text bold color={colorOf(probe.id)}>
172                  {markOf(probe.id)} {probe.item}. {probe.title}
173                </Text>
174                <Button key={`ok-${probe.id}`} label="✓ ok" onPress={() => setVerdict($, probe.id, 'ok', seen)} />
175                <Button key={`no-${probe.id}`} label="✗ falhou" onPress={() => setVerdict($, probe.id, 'falhou', seen)} />
176                {probe.action !== undefined && (
177                  <Button key={`act-${probe.id}`} label={probe.action} variant="primary" onPress={() => act($, probe.id)} />
178                )}
179              </Box>
180              <Text dimColor wrap="wrap">
181                {probe.how}
182              </Text>
183              {detail !== undefined && <Text color="suggestion">→ {detail}</Text>}
184            </Box>
185          )
186        })}
187      </Box>
188      <Box flexDirection="row" gap={1}>
189        <Button key="prev" label="‹" onPress={() => update($, page, n => Math.max(0, n - 1))} />
190        <Button key="next" label="›" onPress={() => update($, page, n => Math.min(pages - 1, n + 1))} />
191      </Box>
192      <Box borderStyle="round" borderColor="subtle" paddingX={1}>
193        <Text>{footer}</Text>
194      </Box>
195    </Box>
196  )
197}
198
199export const register: Register = on => {
200  on('session.start', async ($, e, next) => {
201    const ran = await next(e)
202
203    await $.command.register({ name: 'spike', description: 'Abre o Laboratório do spike da HUD' })
204    await $.command.register({ name: 'spike-relatorio', description: 'Relatório do spike para colar no DECISOES.md' })
205
206    const sessions = Number((await $.store.get('sessoes')) ?? 0) + 1
207    await $.store.set('sessoes', sessions)
208    const stored = (await $.store.get('verdicts')) as Partial<Record<ProbeId, Verdict>> | undefined
209    if (stored !== undefined) await update($, verdicts, marks => ({ ...stored, ...marks }))
210    const version = (await $.session.version()).version
211    await update($, measures, m => ({ ...m, sessions, version }))
212    await refreshTheme($)
213
214    // Sonda 11: aberto sem pedido, o painel só aparece em tela larga.
215    const opened = await $.ui.open({ id: PANE, title: 'Laboratório' })
216    await update($, placement, p => ({
217      ...p,
218      isPlaced: opened.isPlaced,
219      reason: opened.isPlaced ? undefined : String(opened.reason),
220    }))
221
222    return ran
223  })
224
225  on('command.run', { command: 'spike' }, async $ => {
226    await refreshTheme($)
227    const opened = await $.ui.open({ id: PANE, title: 'Laboratório', focus: true })
228    return { text: opened.isPlaced ? 'Laboratório aberto.' : `O Laboratório não abriu: ${String(opened.reason)}` }
229  })
230
231  on('command.run', { command: 'spike-relatorio' }, async $ => ({
232    text: reportText(await snapshot($), await read($, verdicts)),
233  }))
234
235  on('config.set', { key: 'theme' }, async ($, e, next) => {
236    const ran = await next(e)
237    await refreshTheme($)
238    $.ui.invalidate('ui.render')
239    return ran
240  }).catch(($, e, next) => next(e))
241
242  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
243
244  // Faixa: o que está rodando agora.
245  on('tool.call', async ($, e, next) => {
246    await update($, working, () => ({ tool: e.tool, cls: classOf(e.tool), summary: summaryOf(e) }))
247    return next(e)
248  }).catch(($, e, next) => next(e))
249
250  // Sonda 3b: proteção de verdade, só para spike-sandbox/. Falha fechada.
251  const failClosed = { deny: 'clawd-hud-spike: o guarda falhou; alteração bloqueada.' }
252
253  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
254    if (!PROTECTED.test(e.file_path)) return next(e)
255    return (await guardEdit($, 'Edit', e.file_path, deltaOf(e))) ?? next(e)
256  }).catch(($, e, next) => (next.called ? next(e) : failClosed))
257
258  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
259    if (!PROTECTED.test(e.file_path)) return next(e)
260    return (await guardEdit($, 'Write', e.file_path, deltaOf(e))) ?? next(e)
261  }).catch(($, e, next) => (next.called ? next(e) : failClosed))
262
263  on('turn.complete', async ($, e, next) => {
264    const ran = await next(e)
265    if (e.agentId === undefined) {
266      await update($, working, () => null)
267      const answer = e.answer ?? ''
268      await update($, turn, () => ({
269        reason: e.reason,
270        durationMs: e.durationMs,
271        answerHead: answer.slice(0, 80).replace(/\s+/g, ' '),
272        answerChars: answer.length,
273      }))
274    }
275    return ran
276  })
277
278  // Sonda 3: a linha de arquivos acima do diálogo do Claude.
279  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
280    const files = await read($, guard)
281    if (files.length === 0) return next(e)
282
283    // A ferramenta aceita descrição por opção, embora o $.ui.ask não a passe:
284    // a sonda 3 confere se o desktop desenha o que o hook acrescenta aqui.
285    const questions = e.props.questions.map(q => {
286      const options = (q as { options?: { label: string }[] }).options
287      return options === undefined
288        ? q
289        : { ...(q as object), options: options.map(o => ({ ...o, description: GUARD_HINT[o.label] ?? '' })) }
290    })
291    const dialog = await next({ ...e, props: { ...e.props, questions } })
292
293    const ui = $.ui.resolve(e)
294    const { Box, Text } = ui
295    if (e.surface !== 'desktop') {
296      const names = files.map(f => `${f.name} +${f.added}${f.removed > 0 ? ` −${f.removed}` : ''}`).join(' · ')
297      return (
298        <Box flexDirection="column">
299          <Text color="warning">⚿ {names}</Text>
300          {dialog}
301        </Box>
302      )
303    }
304    const palette = paletteOf((await read($, measures)).theme)
305    return (
306      <Box flexDirection="column">
307        {svgOf(ui, guardLine(files, palette))}
308        {dialog}
309      </Box>
310    )
311  })
312
313  // Sonda 8 (parte 2): com as leituras recolhidas, a saída delas também some, como na HUD.
314  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
315    if (!READS.test(e.props.tool) || !(await read($, collapseReads))) return next(e)
316    const { Box } = $.ui.resolve(e)
317    return <Box />
318  })
319
320  // Sonda 8: o Clawd à esquerda de cada ferramenta; leituras recolhíveis.
321  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
322    if (READS.test(e.props.tool) && !e.props.isRunning && (await read($, collapseReads))) {
323      const { Box } = $.ui.resolve(e)
324      return <Box />
325    }
326    const row = await next(e)
327    if (e.surface !== 'desktop') return row
328
329    const ui = $.ui.resolve(e)
330    const { Box } = ui
331    const palette = paletteOf((await read($, measures)).theme)
332    const cls = classOf(e.props.tool)
333    return (
334      <Box flexDirection="row" gap={1}>
335        {svgOf(ui, crab(cls, 40, palette, { tip: `${CLASS_NAME[cls]} · ${e.props.tool}` }))}
336        <Box flexDirection="column" flexGrow={1}>
337          {row}
338        </Box>
339      </Box>
340    )
341  })
342
343  // Faixa acima do prompt: o Clawd de 90 px enquanto o Claude trabalha; some no fim.
344  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345    const now = await read($, working)
346    if (!e.props.isWorking || e.props.hasSurvey || now === null) return next(e)
347
348    const ui = $.ui.resolve(e)
349    const { Box, Text } = ui
350    const palette = paletteOf((await read($, measures)).theme)
351    const below = await next(e)
352    const title = `${CLASS_NAME[now.cls]} · ${now.tool}`
353    return (
354      <Box flexDirection="column">
355        <Box flexDirection="row" gap={2} alignItems="center">
356          {e.surface === 'desktop' && svgOf(ui, crab(now.cls, 90, palette, { withProp: true, tip: title }))}
357          <Box flexDirection="column">
358            <Text bold color={classColor(now.cls, palette)}>
359              {title}
360            </Text>
361            <Text dimColor wrap="truncate-end">
362              {now.summary}
363            </Text>
364          </Box>
365        </Box>
366        {below}
367      </Box>
368    )
369  })
370}
371
hooks/art.ts 164 lines
1// Arte do Clawd portada do protótipo final (prototype/hud-prototype-final.html):
2// corpo em pixel, chapéus e acessórios por classe. Só texto de SVG, sem script:
3// a animação vem de CSS (<style>) e de SMIL (<animate>), que o Svg interativo do
4// desktop deve tocar. É isso que a sonda 1 confere.
5
6import type { Cls, GuardFile } from '../types'
7
8export type Palette = 'escuro' | 'claro'
9
10const CLASS_COLOR: Record<Palette, Record<Cls, string>> = {
11  escuro: { druida: '#f0a272', mago: '#6cc3ec', artifice: '#e9b75c', paladino: '#f08fb8' },
12  claro: { druida: '#a84e1a', mago: '#136b98', artifice: '#855a06', paladino: '#a83b6c' },
13}
14
15const RANGER: Record<Palette, string> = { escuro: '#5fc7b5', claro: '#147a69' }
16
17export const CLASS_NAME: Record<Cls, string> = {
18  druida: 'Clawd',
19  mago: 'Mago',
20  artifice: 'Artífice',
21  paladino: 'Paladino',
22}
23
24export const classColor = (cls: Cls, palette: Palette) => CLASS_COLOR[palette][cls]
25
26/** Paleta pelo valor de tema que o Claude Code guarda (`dark`, `light`, `light-ansi`...). */
27export const paletteOf = (theme: string | undefined): Palette =>
28  theme !== undefined && /light/i.test(theme) ? 'claro' : 'escuro'
29
30const BODY = [
31  '..OOOOOOOOOO..',
32  '..OOEOOOOEOO..',
33  'OOOOOOOOOOOOOO',
34  '..OOOOOOOOOO..',
35  '..OOOOOOOOOO..',
36  '..O.O....O.O..',
37]
38
39const KEYFRAMES = [
40  '.eye{animation:blink 4.4s infinite}',
41  '@keyframes blink{0%,94%,100%{opacity:1}96%{opacity:0}}',
42  '.gearr{transform-box:fill-box;transform-origin:center;animation:gr 4s linear infinite}',
43  '@keyframes gr{to{transform:rotate(360deg)}}',
44  '.mag{animation:mag 2.6s ease-in-out infinite}',
45  '@keyframes mag{50%{transform:translateY(9px)}}',
46  '.page{transform-box:fill-box;transform-origin:left center;animation:page 2.2s ease-in-out infinite}',
47  '@keyframes page{50%{transform:scaleX(-1)}}',
48].join('')
49
50const body = () => {
51  let out = ''
52  BODY.forEach((row, y) =>
53    [...row].forEach((ch, x) => {
54      if (ch === '.') return
55      const X = 30 + x * 5
56      const Y = 50 + y * 5
57      out += `<rect x="${X}" y="${Y}" width="5.2" height="5.2" rx=".8" fill="#d97757"/>`
58      if (ch === 'E') out += `<rect class="eye" x="${X}" y="${Y}" width="5" height="5" rx="1.1" fill="#1a1a1a"/>`
59    }),
60  )
61  return out + '<rect x="41" y="51" width="22" height="2.2" rx="1.1" fill="#fff" opacity=".22"/>'
62}
63
64const hat = (cls: Cls, palette: Palette) => {
65  const c = classColor(cls, palette)
66  switch (cls) {
67    case 'mago':
68      return `<polygon points="47,50 83,50 70,14" fill="${c}"/><ellipse cx="65" cy="50" rx="22" ry="4" fill="${c}"/><text x="62" y="40" font-size="10" fill="#f2c14e">✦</text>`
69    case 'artifice':
70      return `<rect x="40" y="46" width="50" height="3" fill="#8a6a45"/><rect x="47" y="41" width="13" height="9" rx="2" fill="${RANGER[palette]}" stroke="#3b3b45" stroke-width="2"/><rect x="70" y="41" width="13" height="9" rx="2" fill="${RANGER[palette]}" stroke="#3b3b45" stroke-width="2"/>`
71    case 'paladino':
72      return '<rect x="45" y="51" width="15" height="13" rx="2" fill="#fff" fill-opacity=".3" stroke="#1a1a1a" stroke-width="3"/><rect x="70" y="51" width="15" height="13" rx="2" fill="#fff" fill-opacity=".3" stroke="#1a1a1a" stroke-width="3"/><line x1="60" y1="56" x2="70" y2="56" stroke="#1a1a1a" stroke-width="3"/>'
73    case 'druida':
74      // O brilho do druida usa SMIL, e não CSS, para a sonda 1 ver os dois tipos.
75      return `<text x="31" y="32" font-size="11" fill="#f2c14e">✦<animate attributeName="opacity" values="1;.3;1" dur="2.6s" repeatCount="indefinite"/></text><text x="92" y="28" font-size="9" fill="${c}">✧<animate attributeName="opacity" values=".3;1;.3" dur="2.6s" repeatCount="indefinite"/></text>`
76  }
77}
78
79const prop = (cls: Cls) => {
80  switch (cls) {
81    case 'artifice':
82      return `<g class="gearr"><circle cx="16" cy="44" r="7" fill="#e9b75c"/><circle cx="16" cy="44" r="3" fill="#3b3b45"/>${[0, 45, 90, 135]
83        .map(a => `<rect x="14.5" y="35" width="3" height="18" fill="#e9b75c" transform="rotate(${a} 16 44)"/>`)
84        .join('')}</g>`
85    case 'paladino':
86      return '<g class="mag"><circle cx="17" cy="52" r="7" fill="#7dcfff" fill-opacity=".25" stroke="#e86fa3" stroke-width="2.5"/><line x1="22" y1="57" x2="28" y2="63" stroke="#e86fa3" stroke-width="3"/></g>'
87    case 'mago':
88      return '<rect x="53" y="68" width="12" height="9" fill="#e0def4"/><rect class="page" x="65" y="68" width="12" height="9" fill="#c0caf5"/>'
89    case 'druida':
90      return ''
91  }
92}
93
94const esc = (text: string) =>
95  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
96
97export type Crab = { source: string; width: number; height: number; alt: string }
98
99/** O Clawd de uma classe; com `withProp`, o acessório animado entra no quadro. */
100export const crab = (
101  cls: Cls,
102  width: number,
103  palette: Palette,
104  { withProp = false, tip = CLASS_NAME[cls] }: { withProp?: boolean; tip?: string } = {},
105): Crab => {
106  const viewBox = withProp ? '0 10 130 76' : '28 12 76 74'
107  const height = Math.round(withProp ? (width * 76) / 130 : (width * 74) / 76)
108  const source =
109    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width}" height="${height}">` +
110    `<title>${esc(tip)}</title><style>${KEYFRAMES}</style>` +
111    body() +
112    hat(cls, palette) +
113    (withProp ? prop(cls) : '') +
114    '</svg>'
115  return { source, width, height, alt: tip }
116}
117
118const FILE_ICON = {
119  env: (c: string) =>
120    `<rect x="1" y="1" width="14" height="18" rx="2" fill="none" stroke="${c}" stroke-width="1.6"/><circle cx="6.5" cy="12" r="2.6" fill="none" stroke="${c}" stroke-width="1.6"/><line x1="9" y1="12" x2="13" y2="12" stroke="${c}" stroke-width="1.6"/><line x1="11.5" y1="12" x2="11.5" y2="14.5" stroke="${c}" stroke-width="1.6"/>`,
121  pipeline: (c: string) =>
122    `<circle cx="3.5" cy="4" r="2.5" fill="${c}"/><circle cx="12.5" cy="10" r="2.5" fill="${c}"/><circle cx="3.5" cy="16" r="2.5" fill="${c}"/><path d="M3.5 6.5 V13.5 M5.5 5 L10.5 9 M5.5 15 L10.5 11" stroke="${c}" stroke-width="1.4" fill="none"/>`,
123}
124
125/**
126 * A linha que vai acima do diálogo de proteção: ícone, nome e +/− de cada
127 * arquivo. Com `<title>` por arquivo, para a sonda 3 conferir o tooltip.
128 */
129export const guardLine = (files: readonly GuardFile[], palette: Palette) => {
130  const text = palette === 'escuro' ? '#e8e2d6' : '#2b2724'
131  const add = palette === 'escuro' ? '#8fc46a' : '#3d7a1a'
132  const del = palette === 'escuro' ? '#ee7a8e' : '#b0324c'
133  const legendary = palette === 'escuro' ? '#f0a272' : '#a84e1a'
134  let x = 0
135  let out = ''
136  for (const file of files) {
137    const kind = /pipelines?.*\.ya?ml$/i.test(file.name) ? 'pipeline' : 'env'
138    const delta =
139      `<tspan fill="${add}">+${file.added}</tspan>` + (file.removed > 0 ? ` <tspan fill="${del}">−${file.removed}</tspan>` : '')
140    const label = `${esc(file.name)} `
141    const width = 26 + (label.length + String(file.added).length + (file.removed > 0 ? String(file.removed).length + 3 : 1)) * 7.2
142    out +=
143      `<g transform="translate(${x} 4)"><title>${esc(file.name)}: protegido</title>` +
144      `<g>${FILE_ICON[kind](legendary)}</g>` +
145      `<text x="22" y="14" font-family="system-ui,sans-serif" font-size="12.5" fill="${text}">${label}${delta}</text></g>`
146    x += width + 14
147  }
148  const width = Math.max(1, Math.ceil(x))
149  return {
150    source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} 26" width="${width}" height="26">${out}</svg>`,
151    width,
152    height: 26,
153    alt: files.map(f => `${f.name} +${f.added}${f.removed > 0 ? ` −${f.removed}` : ''}`).join(', '),
154  }
155}
156
157/** Classe da linha do diário pela ferramenta (o mapa completo fica para a Fase 1). */
158export const classOf = (tool: string): Cls => {
159  if (/^(Read|Grep|Glob|WebFetch|WebSearch)$/.test(tool)) return 'mago'
160  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'artifice'
161  if (tool === 'Bash') return 'artifice'
162  return 'druida'
163}
164
hooks/guard.ts 24 lines
1// Peças puras da proteção pelo diálogo do Claude (`$.ui.ask`). O fluxo, que
2// usa `$`, fica no register.tsx.
3
4export const GUARD_OPTIONS = ['Aprovar', 'Editar no chat', 'Recusar'] as const
5
6export const GUARD_HINT: Record<string, string> = {
7  Aprovar: 'O Claude grava a mudança como pediu.',
8  'Editar no chat': 'Bloqueia agora e pede para você ajustar a mudança no chat.',
9  Recusar: 'Bloqueia; a resposta final e o relatório registram a recusa.',
10}
11
12/** Só a pasta do spike: o guarda de verdade (Fase 2) tem a lista completa. */
13export const PROTECTED = /(^|[\\/])spike-sandbox[\\/](\.env[^\\/]*|[^\\/]*pipelines?[^\\/]*\.ya?ml)$/i
14
15const lines = (text: string) => (text === '' ? 0 : text.split('\n').length)
16
17/** +/− aproximados pelo input da ferramenta, sem ler o disco. */
18export const deltaOf = (input: { old_string?: string; new_string?: string; content?: string }) => {
19  if (input.content !== undefined) return { added: lines(input.content), removed: 0 }
20  const removed = lines(input.old_string ?? '')
21  const added = lines(input.new_string ?? '')
22  return { added, removed }
23}
24
hooks/lab.ts 169 lines
1// Catálogo das sondas e o texto do relatório. Só funções puras: o que usa `$`
2// fica no register.tsx, porque o motor não segue o `$` através de um import.
3
4import type { Measures, Placement, ProbeId, TurnSeen, Verdict } from '../types'
5
6type Probe = { id: ProbeId; item: string; title: string; how: string; action?: string }
7
8export const PROBES: readonly Probe[] = [
9  {
10    id: 'svg',
11    item: '1',
12    title: 'Svg interativo',
13    how: 'O Clawd pisca (CSS), as estrelas pulsam (SMIL), a engrenagem gira e o tooltip aparece ao parar o mouse.',
14  },
15  {
16    id: 'tema',
17    item: '2',
18    title: 'Tema',
19    how: 'Troque o tema em /config e depois o claro/escuro do app. As cores do Clawd acompanham?',
20  },
21  {
22    id: 'ask',
23    item: '3',
24    title: 'Diálogo + linha de arquivos',
25    how: 'A linha com ícones e +/− aparece acima do diálogo do Claude?',
26    action: 'Testar diálogo',
27  },
28  {
29    id: 'protecao',
30    item: '3b',
31    title: 'Proteção de verdade',
32    how: 'Peça ao Claude: "mude API_URL em spike-sandbox/.env.teste". O diálogo aparece com a linha? O diálogo nativo de permissão aparece também?',
33  },
34  {
35    id: 'fill',
36    item: '4',
37    title: 'Levar ao prompt',
38    how: 'O texto de teste aparece na caixa de digitação?',
39    action: 'Levar ao prompt',
40  },
41  {
42    id: 'turno',
43    item: '6',
44    title: 'Fim do turno',
45    how: 'Depois de uma resposta, o motivo e o começo da resposta final aparecem aqui?',
46  },
47  {
48    id: 'rodape',
49    item: '7',
50    title: 'Rodapé fixo',
51    how: 'Ao paginar com ‹ ›, o rodapé (Mochila · Mana · Vigor) fica parado embaixo?',
52  },
53  {
54    id: 'tooluse',
55    item: '8',
56    title: 'Linhas de ferramenta',
57    how: 'Cada ferramenta no chat ganha o Clawd à esquerda? Com "Recolher leituras", as leituras e a saída delas somem sem deixar linha vazia?',
58    action: 'Recolher leituras',
59  },
60  {
61    id: 'ferramentas',
62    item: '9/10',
63    title: 'Sessão paralela e ccd_*',
64    how: 'Lista as ferramentas do app que abrem sessão ou painel. Nenhuma é chamada.',
65    action: 'Listar',
66  },
67  {
68    id: 'painel',
69    item: '11',
70    title: 'Painel sem pedido',
71    how: 'O Laboratório abriu sozinho no início da sessão? A largura medida entra no relatório quando você marca ✓ ou ✗.',
72  },
73  {
74    id: 'store',
75    item: '12',
76    title: '$.store entre sessões',
77    how: 'Abra outra sessão e rode /spike. O número de sessões subiu?',
78  },
79  {
80    id: 'faixa',
81    item: '+',
82    title: 'Faixa acima do prompt',
83    how: 'Enquanto o Claude trabalha aparece o Clawd de 90 px com a atividade, e a faixa some no fim do turno?',
84  },
85]
86
87/** O que o Pane e o relatório leem do estado, num retrato só. */
88export type Snapshot = {
89  measures: Measures
90  turn: TurnSeen | null
91  collapseReads: boolean
92  placement: Placement
93}
94
95export const describePlacement = (p: Placement) => {
96  const parts: string[] = []
97  if (p.isPlaced !== undefined) parts.push(p.isPlaced ? 'abriu sozinho' : `não abriu (${p.reason ?? 'sem motivo'})`)
98  if (p.placement !== undefined) parts.push(p.placement)
99  if (p.columns !== undefined) parts.push(`${p.columns}×${p.rows ?? '?'} células`)
100  if (p.isFullscreen !== undefined) parts.push(p.isFullscreen ? 'docka' : 'não docka')
101  if (p.bodyRows !== undefined) parts.push(`corpo ${p.bodyColumns ?? '?'}×${p.bodyRows}`)
102  return parts.length === 0 ? undefined : parts.join(' · ')
103}
104
105/** O valor que cada sonda mediu, em uma linha. */
106export const measuredLine = (snap: Snapshot, id: ProbeId): string | undefined => {
107  const m = snap.measures
108  switch (id) {
109    case 'tema':
110      return m.theme === undefined ? undefined : `tema lido: ${m.theme}`
111    case 'ask':
112      return m.ask === undefined ? undefined : `resposta: ${m.ask}`
113    case 'fill':
114      return m.fill
115    case 'turno': {
116      const t = snap.turn
117      return t === null
118        ? undefined
119        : `${t.reason} · ${Math.round(t.durationMs / 100) / 10}s · ${t.answerChars} car. · "${t.answerHead}"`
120    }
121    case 'tooluse':
122      return snap.collapseReads ? 'leituras recolhidas' : 'leituras visíveis'
123    case 'ferramentas':
124      return m.tools
125    case 'painel':
126      return describePlacement(snap.placement)
127    case 'store':
128      return m.sessions === undefined ? undefined : `sessões contadas: ${m.sessions}`
129    default:
130      return undefined
131  }
132}
133
134const cell = (text: string) => text.replace(/\|/g, '\\|').replace(/\n/g, ' ')
135
136/** O Markdown que o /spike-relatorio devolve. */
137export const reportText = (snap: Snapshot, marks: Partial<Record<ProbeId, Verdict>>) => {
138  const m = snap.measures
139  const lines = [
140    '## Spike clawd-hud-spike: resultado no app real',
141    '',
142    `- Motor: ${m.version ?? '?'} · superfície: ${m.surface ?? '?'} · tema lido: ${m.theme ?? '?'}`,
143    '',
144    '| Item | Sonda | Veredito | Medido |',
145    '|---|---|---|---|',
146  ]
147  for (const probe of PROBES) {
148    const verdict = marks[probe.id] ?? 'não verificado'
149    lines.push(`| ${probe.item} | ${probe.title} | ${verdict} | ${cell(measuredLine(snap, probe.id) ?? '')} |`)
150  }
151  lines.push('| 5 | Client como botão | descartado pelos tipos | Client não aceita Svg (2.1.295) |')
152  return lines.join('\n')
153}
154
155/** Rodapé do protótipo: o que resta das janelas de uso. */
156export const footerText = (usage: {
157  context: { percent?: number }
158  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
159}) => {
160  const left = (kind: string) => {
161    const w = usage.rateLimits.find(r => r.kind === kind)
162    if (w === undefined) return '—'
163    const at = w.resetsAt === undefined ? '' : ` (renova ${w.resetsAt.slice(11, 16)} UTC)`
164    return `${Math.round(100 - w.percentUsed)}%${at}`
165  }
166  const ctx = usage.context.percent
167  return `Mochila ${ctx === undefined ? '—' : `${Math.round(ctx)}%`} · Mana ${left('five_hour')} · Vigor ${left('seven_day')}`
168}
169
types/index.d.ts 69 lines
1export type Verdict = 'ok' | 'falhou'
2
3export type ProbeId =
4  | 'svg'
5  | 'tema'
6  | 'ask'
7  | 'protecao'
8  | 'fill'
9  | 'turno'
10  | 'rodape'
11  | 'tooluse'
12  | 'ferramentas'
13  | 'painel'
14  | 'store'
15  | 'faixa'
16
17export type Cls = 'druida' | 'mago' | 'artifice' | 'paladino'
18
19/** Linha de arquivo protegido desenhada acima do diálogo (sonda 3/3b). */
20export type GuardFile = { name: string; added: number; removed: number }
21
22/** O que está rodando agora, para a faixa acima do prompt. */
23export type Working = { tool: string; cls: Cls; summary: string }
24
25/** O que a sonda 6 viu no último `turn.complete`. */
26export type TurnSeen = {
27  reason: string
28  durationMs: number
29  answerHead: string
30  answerChars: number
31}
32
33/** O que a sonda 11 viu ao abrir o Pane sem pedido e ao desenhá-lo. */
34export type Placement = {
35  isPlaced?: boolean
36  reason?: string
37  placement?: string
38  columns?: number
39  rows?: number
40  isFullscreen?: boolean
41  bodyRows?: number
42  bodyColumns?: number
43}
44
45export type Measures = {
46  theme?: string
47  ask?: string
48  fill?: string
49  tools?: string
50  sessions?: number
51  version?: string
52  surface?: string
53}
54
55declare module 'claude-code' {
56  interface PluginState {
57    'clawd-hud-spike': {
58      verdicts: Partial<Record<ProbeId, Verdict>>
59      measures: Measures
60      guard: GuardFile[]
61      working: Working | null
62      turn: TurnSeen | null
63      placement: Placement
64      collapseReads: boolean
65      page: number
66    }
67  }
68}
69