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

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.
/spike): uma linha por sonda, com ✓ ok e ✗ falhou. Os vereditos ficam no $.store.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.claude plugin validate C:\Projetos\Claude-code-hero-journey\mods\clawd-hud-spike num terminal.git pull em C:\Projetos\Claude-code-hero-journey.%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./spike.| Item | O que fazer | O que observar |
|---|---|---|
| 1 | Olhe os dois Clawds no topo e pare o mouse sobre eles | Piscam (CSS), as estrelas pulsam (SMIL), a engrenagem gira, o tooltip aparece |
| 2 | Troque o tema em /config e depois o claro/escuro do app | As cores dos Clawds mudam? O "tema lido" muda? |
| 3 | Testar diálogo | A linha com ícones e .env.homologacao +1 · azure-pipelines.yml +3 −1 aparece acima do diálogo? As opções têm descrição? |
| 3b | Peç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? |
| 4 | Levar ao prompt | O texto aparece na caixa de digitação? Qual é o resultado gravado? |
| 6 | Mande qualquer pergunta e espere a resposta | O motivo e o começo da resposta aparecem no item 6 |
| 7 | Pagine com ‹ › | O rodapé "Mochila · Mana · Vigor" fica parado embaixo? Os números batem com o uso real? |
| 8 | Peça ao Claude para ler um arquivo; depois clique em Recolher leituras e peça outra leitura | O Clawd aparece à esquerda das linhas? Recolhida, a leitura some sem deixar uma linha vazia? |
| 9/10 | Listar | Quais ferramentas do app aparecem (mcp__ccd_*, sessão, diff)? |
| 11 | Repare se o Laboratório abriu sozinho ao começar a sessão | Veja a largura medida e se "docka" |
| 12 | Abra uma segunda sessão e rode /spike | O número de sessões subiu? |
| + | Mande o Claude rodar algo demorado | A 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.
/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.Remova as duas chaves do bloco "env" e reinicie o app.
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.
hooks/register.tsx 371 lines1// 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}
371hooks/art.ts 164 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
164hooks/guard.ts 24 lines1// 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}
24hooks/lab.ts 169 lines1// 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}
169types/index.d.ts 69 lines1export 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