HUD de RPG aconchegante para o Claude Code, com os caranguejos Clawd multiclasse (pt-BR e English)

Português · English
Uma HUD de RPG aconchegante para o Claude Code desktop, com os caranguejos Clawd multiclasse. A saída do Claude não muda: a HUD só acrescenta os mascotes, o painel, a faixa e os cartões.
Beta. Feita primeiro para o app desktop. O suporte ao Claude Code CLI (terminal) está pendente e vem depois da versão desktop.
Com o Claude Code (testado no motor 2.1.293):
/plugin install hero-journey --marketplace soulrocha/Claude-code-hero-journey
O mod instalado pelo terminal no escopo de usuário também carrega nas sessões do app desktop. Depois, digite /hero.
As opções da tabela de Configurações podem ficar vazias. Para respondê-las, use /plugin configure hero-journey@claude-code-hero-journey dentro do Claude Code, ou --config chave=valor no claude plugin install. A instalação pelo terminal só avisa quais opções ficaram sem valor. Na primeira vez que o painel abre, a página Primeiros passos pergunta de novo a língua, se você usa um vault (onde ele fica e qual a pasta de entrada, conferindo se as pastas existem) e se quer cadastrar a pasta atual como projeto. O ai-memory não pede pasta: o grimório grava nele pelo MCP, quando o MCP está conectado. Sem vault, a tela recomenda o Obsidian; sem o MCP do ai-memory, recomenda o ai-memory. Para voltar a ela, use ⚙ → ✦ Primeiros passos.
Cada módulo liga e desliga no ⚙ do painel → Módulos. Todos nascem ligados.
| Módulo | O que faz | Gasta tokens? |
|---|---|---|
| Diário de bordo | Em cada ferramenta: o Clawd da classe, o trilho colorido e o chip (✓/✗, "novo", +/−, tempo). Chamadas seguidas da mesma classe viram um grupo ("Artífice · 2 ações · ✓ novo · ✓ +42 −8"), e Separar › abre só aquele. | Não |
| Faixa acima do prompt | Enquanto o Claude trabalha: o Clawd de 90 px com o acessório animado, "Classe · o que faz" e a intenção que o Claude escreveu antes das ferramentas. Some no fim do turno. | Não |
Painel (/hero) | Objetivo → Quests do capítulo → Grupo e Inventário (abas ◆/◇, ⊞/⊟) → Relatório da missão → rodapé com Mochila, Mana, Vigor, 📖, ⇄, ⚙ e ℹ. | Não |
| Campanha e relatório | A sessão é a campanha: a 1ª mensagem dá o título e o objetivo, cada mensagem abre um capítulo. No fim de cada turno, o relatório abre sozinho com a linha do tempo, o resultado (lido do formato briefing-de-bordo) e os botões ✓ Aprovar e ✎ Ajustar. | Não |
| Guarda dos protegidos | Antes de editar .env*, pipelines, workflows, appsettings de produção e chaves, pergunta. Também pergunta antes de um comando de terminal que escreveria neles (>>, sed -i, rm, Set-Content…). Três modos no ⚙: Diálogo do Claude (padrão, com a linha de arquivos por cima), Encontro com chefe (o Guardião do Cofre com os diffs) e Só o diálogo nativo. Se algo falhar, bloqueia. | Não |
| Mochila | Quando o contexto passa de 85%, ou quando você clica em Mochila: o cartão leva a instrução do /compact ao prompt, ou Compactar agora (só com o Claude parado). | Só no Compactar agora |
| Grimório | No fim de cada turno, acha sem chamar o modelo as decisões técnicas, as conquistas com número e as suas preferências de trabalho com IA. Sugere o destino de cada uma, já marcado e explicado, e grava só quando você clica em Selar. | Só no botão opcional ✦ Resumir com Haiku |
Side quest (/side-quest ou ⇄) | Um desvio numa sessão paralela: o cartão monta o resumo da campanha e leva ao prompt o pedido para o Claude abrir a sessão. | Não (a sessão paralela gasta quando roda) |
| Comemoração | No fim de uma missão com ação e sem falha, o Clawd festeja num toast. | Não |
Projetos (/projetos ou 🗂) | Projetos com várias pastas: os fixados no topo e o da pasta atual em destaque. Cada pasta mostra se o Claude a alcança (✓/✗). Acesso nesta sessão leva /add-dir <pasta> ao prompt; Acesso sempre acrescenta as pastas ao permissions.additionalDirectories do .claude/settings.local.json da pasta da sessão. Abrir sessão pede ao Claude uma sessão nova no projeto. | Não |
| Classe | Faz | Acessório |
|---|---|---|
| Clawd druida | Sessão principal, planos e tarefas | brilhos |
| Mago | Leitura, busca e pesquisa | livro |
| Ilusionista | UI, protótipos e design | pincel |
| Artífice | Código | engrenagem |
| Paladino | Testes, lint e revisão | lupa |
| Patrulheiro | Pipeline, deploy e logs | luneta |
| Anão minerador | Banco de dados e migrações | picareta |
| Arauto | Commit, push, PR e merge | carimbo |
| Alquimista | Integrações (MCP, HTTP) | bolhas |
| Raridade | Arquivo |
|---|---|
| Lendário | Protegido (segue a lista do guarda) |
| Raro | Criado |
| Incomum | Modificado |
| Pobre | Excluído |
O topo mostra o branch, ↑commits à frente e ● arquivos sem commit.
Em /config (linhas do mod):
| Opção | Para quê |
|---|---|
language | auto (padrão) segue a língua do Claude: a configuração language do Claude Code, depois o LANG do sistema, depois a sua primeira mensagem. pt-BR ou en fixam a língua. |
protect | Globs extras protegidos, separados por vírgula (ex.: **/secrets.json). |
allow | Globs que o guarda nunca bloqueia (ex.: .env.example). |
vaultPath | Pasta do vault (ex.: C:\Notas). Vazia, o grimório não oferece os destinos do vault e da carreira. |
vaultInbox | Subpasta do vault onde o grimório deixa as notas novas. Padrão: Inbox. |
No ⚙ do painel (guardado entre as sessões):
| Tipo | Vem marcado | Onde grava |
|---|---|---|
| Decisão técnica | Vault | <vault>/<vaultInbox>/decisao-pendente-<data>-<tema>.md |
| Conquista com número | Carreira | <vault>/<vaultInbox>/resultado-pendente-<data>-<tema>.md |
| Preferência de IA | ai-memory | Perfil do ai-memory, pelo MCP: memory_write_page com scope: "profile" em workflow/<data>-<tema>-<hora>.md (scope: "global" nas versões sem perfil) |
| Opcional, desmarcado | CLAUDE.md e AGENTS.md | Uma linha sob ## Decisões na raiz do projeto: uma linha só, sem crase nem tag, até 280 caracteres. Esses arquivos viram instrução permanente do Claude, por isso só com você marcando. |
-2, -3…/config ou do instalador: a pasta de entrada precisa ser uma subpasta do vault.mcp__<servidor>__memory_write_page), então funciona mesmo com o servidor renomeado.Privacidade: se o texto cita o vault, o ai-memory ou qualquer caminho pessoal (C:\…, /home/…, /Users/…, ~/), ele não vai para o CLAUDE.md nem para o AGENTS.md.
cat, Get-Content, grep…). Por isso git add .env também pergunta. Um script que monta o caminho por dentro, sem citá-lo, passa. O modo Só o diálogo nativo apenas avisa no diálogo de permissão do Claude; se as edições estiverem liberadas automaticamente, nada pergunta.mods/clawd-hud-spike):prompt.fill no desktop;O código desta HUD é próprio. Alguns padrões vieram dos mods do claude-code-templates, de Daniel (San) Ávila, sob licença MIT (aviso em THIRD_PARTY_NOTICES.md). Cada módulo cita de onde veio o padrão.
| Mod de origem | O que veio dele | Onde está aqui |
|---|---|---|
| protected-paths-guard | Globs, o globToRegExp e o .catch que falha fechado | modules/guarda |
| large-edit-confirmation | O $.ui.ask dentro do tool.call, o "Outro" não aprovar e negar sem ninguém | modules/guarda |
| tool-timing-badge | Embrulhar o ToolUse e medir o tempo | modules/diario |
| agent-flow | Acompanhar subagentes pelo agent.spawn | modules/painel (Grupo) |
| git-sidebar | Git por argv, sem shell | modules/painel (Inventário) |
| session-time-machine | A linha do tempo da sessão | modules/campanha |
A side quest se baseia na ideia do auto-handoff e do /btw. Os créditos com link entram quando o endereço desses mods estiver confirmado.
Estes não fazem parte da HUD: instale direto do repositório de quem fez, depois de ler o código.
| Mod | Para quê | Como instalar |
|---|---|---|
| secret-redactor | Esconde segredos | npx claude-code-templates --mod security/secret-redactor |
| prompt-cache-control | Mede o cache | npx claude-code-templates --mod observability/prompt-cache-control |
| rulebook-guard, blast-radius / Launch Codes, session-saver, md-preview | Adotados no handoff | Links a confirmar |
Não instale o protected-paths-guard junto com o guarda da HUD: ele nega direto, e a HUD pergunta.
claude plugin validate mods/hero-journey
claude plugin test mods/hero-journey
# ou tudo de uma vez: scripts/verificar.ps1 (Windows) / scripts/verificar.sh
Estrutura:
| Caminho | Conteúdo |
|---|---|
hooks/register.tsx | Liga os módulos aos eventos. Todo o código que usa $ fica aqui, porque o motor não segue o $ através de imports. |
hooks/core/ | Arte em Svg, textos pt/en, classes, preferências e o que todas as telas usam |
hooks/modules/<módulo>/ | A lógica (<módulo>.ts, pura) e a tela (view.tsx) de cada módulo |
tests/ | 85 testes no motor, em desktop e terminal, incluindo as regressões dos QAs e da revisão do PR |
A cozy RPG HUD for the Claude Code desktop app, with the multiclass Clawd crabs. Claude's output stays the same.
Beta. Built for the desktop app first. Claude Code CLI (terminal) support is pending and comes after the desktop version.
Install (tested on engine 2.1.293): /plugin install hero-journey --marketplace soulrocha/Claude-code-hero-journey, then type /hero.
Modules (each one toggles in the pane's ⚙ → Modules):
| Module | What it does | Spends tokens? |
|---|---|---|
| Logbook | Class mascot and rail on tool rows, grouped runs | No |
| Band above the prompt | The Clawd and Claude's stated intent while it works | No |
| Pane | Objective, quests, party, inventory, mission report, resource footer | No |
| Campaign and report | Chapters and a mission report with a timeline, Approve and Adjust | No |
| Protected-file guard | Asks before Claude edits .env, pipelines and the like, or runs a command that writes them. Dialog, boss-encounter or native-only mode. Fails closed. | No |
| Backpack | Compaction card | Only on Compact now |
| Grimoire | Finds decisions, achievements and AI-work preferences, suggests where to store each (CLAUDE.md and AGENTS.md come unticked; ai-memory over its MCP), and writes only when you click Seal | Only through the optional Summarize with Haiku button |
| Side quest | Drafts a parallel-session request with the campaign summary | No |
| Projects | Pinned projects with several folders; shows and grants Claude's access to each folder | No |
| Celebration | A toast when a mission ends | No |
Language: auto follows Claude's language. Pin pt-BR or en in /config or in the pane's ⚙.
Credits: some code patterns come from Daniel (San) Ávila's claude-code-templates mods (MIT). The details are in the Agradecimentos section above and in each module's header.
MIT © 2026 Saulo Rocha. Os trechos adaptados mantêm o aviso em THIRD_PARTY_NOTICES.md.
hooks/register.tsx 1287 lines1// hero-journey: a HUD de RPG do Claude Code.
2//
3// Liga os módulos de hooks/modules/ aos eventos do motor. Cada módulo tem a sua
4// seção aqui e liga ou desliga no ⚙ (prefs.modules); a lógica e as telas ficam
5// na pasta do módulo. Tudo o que usa `$` mora neste arquivo, porque o motor não
6// segue o `$` através de um import.
7//
8// Gasto de tokens: só o "Compactar agora" da mochila e o Haiku do grimório
9// (desligado por padrão), e os dois só com o clique da pessoa.
10//
11// Créditos dos padrões de código: Daniel Ávila (claude-code-templates, MIT),
12// citado em cada módulo e no README.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
16
17import type { Campaign, Card, DiaryEntry, Destination, GuardFile, Inscription, Lang, ModuleId, Palette, Prefs, Project } from '../types'
18import { agentClass, classOf, clockOf, patchStats, summaryOf, untilOf } from './core/classes'
19import { TONE } from './core/art'
20import { DICT, looksPortuguese, resolveLang } from './core/i18n'
21import { DEFAULT_PREFS, isOn, migratePrefs, toggleModule, toggleSection, withProfile } from './core/prefs'
22import type { Ctx } from './core/ui'
23import { assignTurn, buildReport, chapterOf, closeChapter, cutAtWord, goalOf, reports, onPrompt, prune, storeKey } from './modules/campanha/campanha'
24import { celebrate } from './modules/comemoracao/comemoracao'
25import { groupFor, groupView } from './modules/diario/diario'
26import { groupHeadView, rowView } from './modules/diario/view'
27import { bandView } from './modules/faixa/view'
28import { appendDecision, available, detect, haikuPrompt, isSubfolder, memoryTool, merge, pending, toggleDest, withSuffix, writesFor } from './modules/grimorio/grimorio'
29import type { Dests, Memory, Write } from './modules/grimorio/grimorio'
30import { grimCard, grimPage } from './modules/grimorio/view'
31import { diffOf, LABELS, listOption, makeGuard, shellTargets, verdictOf } from './modules/guarda/guarda'
32import { bossView, guardLineView } from './modules/guarda/view'
33import { COMPACT_AT, compactInstruction, shouldWarn } from './modules/mochila/mochila'
34import { compactCard } from './modules/mochila/view'
35import { EMPTY_INVENTORY, LOG_ARGV, NUMSTAT_ARGV, parseInventory, parseLog, questsFromTodos, questView, STATUS_ARGV, upsertMember } from './modules/painel/painel'
36import { setupView } from './modules/painel/primeiros-passos'
37import { aboutView, configView, mainView } from './modules/painel/view'
38import {
39 addFolder,
40 addProject,
41 baseFolderName,
42 currentProject,
43 hasAccess,
44 isInside,
45 loadProjects,
46 openSessionPrompt,
47 removeFolder,
48 removeProject,
49 settingsDirs,
50 togglePin,
51 withDirectories,
52} from './modules/projetos/projetos'
53import { projectsView } from './modules/projetos/view'
54import { briefOf, sideQuestPrompt } from './modules/side-quest/side-quest'
55import { sideQuestCard } from './modules/side-quest/view'
56
57const PANE = 'hero-journey'
58const PANE_TITLE = 'Hero Journey'
59// `Task` é o nome antigo da ferramenta Agent.
60const AGENT = /^(Agent|Task)$/
61const WRITES = /^(Edit|Write|NotebookEdit)$/
62const CARD_ORDER: readonly Card[] = ['grimorio', 'compactar', 'sideQuest']
63
64const lang = atom({ plugin: 'hero-journey', key: 'lang' } as const, 'en')
65const theme = atom({ plugin: 'hero-journey', key: 'theme' } as const, 'dark')
66const turnId = atom({ plugin: 'hero-journey', key: 'turnId' } as const, '')
67const diary = atom({ plugin: 'hero-journey', key: 'diary' } as const, [])
68const expanded = atom({ plugin: 'hero-journey', key: 'expanded' } as const, [])
69const quests = atom({ plugin: 'hero-journey', key: 'quests' } as const, [])
70const party = atom({ plugin: 'hero-journey', key: 'party' } as const, [])
71const inventory = atom({ plugin: 'hero-journey', key: 'inventory' } as const, EMPTY_INVENTORY)
72const prefs = atom({ plugin: 'hero-journey', key: 'prefs' } as const, DEFAULT_PREFS)
73const working = atom({ plugin: 'hero-journey', key: 'working' } as const, null)
74const cards = atom({ plugin: 'hero-journey', key: 'cards' } as const, [])
75const view = atom({ plugin: 'hero-journey', key: 'view' } as const, 'main')
76const partyTab = atom({ plugin: 'hero-journey', key: 'partyTab' } as const, 'grupo')
77const campaign = atom({ plugin: 'hero-journey', key: 'campaign' } as const, null)
78const reportAt = atom({ plugin: 'hero-journey', key: 'reportAt' } as const, 0)
79const reportOpen = atom({ plugin: 'hero-journey', key: 'reportOpen' } as const, false)
80const guard = atom({ plugin: 'hero-journey', key: 'guard' } as const, [])
81const refusals = atom({ plugin: 'hero-journey', key: 'refusals' } as const, [])
82const inscriptions = atom({ plugin: 'hero-journey', key: 'inscriptions' } as const, [])
83const intent = atom({ plugin: 'hero-journey', key: 'intent' } as const, '')
84const projects = atom({ plugin: 'hero-journey', key: 'projects' } as const, [])
85const sessionDirs = atom({ plugin: 'hero-journey', key: 'sessionDirs' } as const, [])
86const setup = atom({ plugin: 'hero-journey', key: 'setup' } as const, {})
87const confirmDel = atom({ plugin: 'hero-journey', key: 'confirmDel' } as const, '')
88
89const paletteOf = (value: string): Palette => (/light/i.test(value) ? 'claro' : 'escuro')
90
91/** A língua fixada na opção vale na hora; em `auto`, a que o session.start resolveu. */
92const pinned = (option: string): Lang | undefined => (option === 'pt-BR' ? 'pt' : option === 'en' ? 'en' : undefined)
93
94// As opções do mod (lidas a cada carga do módulo) e o que a sessão já disse.
95let langOption = 'auto'
96let isGuarded = makeGuard()
97let paths = { vault: '', inbox: 'Inbox' }
98// A língua da primeira mensagem só decide quando nada mais disse qual é.
99let langFromPrompt = false
100// Sem ninguém para responder (claude -p), o guarda nega.
101let interactive = true
102// O texto que abriu cada turno, para o grimório achar as preferências da pessoa.
103const turnPrompts = new Map<string, string>()
104let warned = false
105// Um Selar por vez: o segundo clique, enquanto o primeiro grava, não faz nada.
106let sealing = false
107
108// ---------- Ajudantes (usam `$`: o validador quer funções no topo do arquivo) ----------
109
110async function langNow($: EngineInterface): Promise<Lang> {
111 return pinned(langOption) ?? (await read($, lang))
112}
113
114async function prefsNow($: EngineInterface): Promise<Prefs> {
115 return read($, prefs)
116}
117
118async function moduleOn($: EngineInterface, id: ModuleId) {
119 return isOn(await prefsNow($), id)
120}
121
122async function ctxOf($: EngineInterface, surface: string, width: number): Promise<Omit<Ctx, 'ui'>> {
123 const p = await prefsNow($)
124 const themeValue = await read($, theme)
125 const palette = paletteOf(themeValue)
126 return {
127 desktop: surface === 'desktop',
128 t: DICT[await langNow($)],
129 palette,
130 skin: p.skin,
131 width,
132 bg: /dark|light/i.test(themeValue) ? TONE[palette].pane : undefined,
133 }
134}
135
136async function refreshTheme($: EngineInterface) {
137 const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
138 await update($, theme, () => (row === undefined ? 'dark' : String(row.value)))
139}
140
141async function refreshInventory($: EngineInterface) {
142 try {
143 const status = await $.process.run([...STATUS_ARGV], { timeoutMs: 5000 })
144 if (status.exitCode !== 0) {
145 await update($, inventory, () => EMPTY_INVENTORY)
146 return
147 }
148 const numstat = await $.process.run([...NUMSTAT_ARGV], { timeoutMs: 5000 })
149 const log = await $.process.run([...LOG_ARGV], { timeoutMs: 5000 }).catch(() => undefined)
150 const inv = parseInventory(status.stdout, numstat.exitCode === 0 ? numstat.stdout : '', isGuarded)
151 await update($, inventory, () => ({ ...inv, recent: log?.exitCode === 0 ? parseLog(log.stdout) : [] }))
152 } catch {
153 await update($, inventory, () => EMPTY_INVENTORY)
154 }
155}
156
157/** Lê o $.store na hora de gravar: outra sessão aberta pode ter mudado as preferências. */
158async function savePrefs($: EngineInterface, change: (p: Prefs) => Prefs) {
159 const stored = await $.store.get('prefs')
160 const next = change(stored === undefined ? await read($, prefs) : migratePrefs(stored))
161 await update($, prefs, () => next)
162 await $.store.set('prefs', next)
163}
164
165async function pushCard($: EngineInterface, card: Card) {
166 await update($, cards, list =>
167 list.includes(card) ? list : [...list, card].sort((a, b) => CARD_ORDER.indexOf(a) - CARD_ORDER.indexOf(b)),
168 )
169}
170
171async function dropCard($: EngineInterface, card: Card) {
172 await update($, cards, list => list.filter(c => c !== card))
173}
174
175/** Leva o texto ao prompt; onde não há caixa, copia. */
176async function fillOrCopy($: EngineInterface, text: string) {
177 const t = DICT[await langNow($)]
178 const r = await $.prompt.fill({ text })
179 if (r.isFilled) {
180 $.ui.toast(t.filled)
181 return true
182 }
183 await $.ui.copy({ text })
184 $.ui.toast(t.noComposer)
185 return false
186}
187
188const resetText = (t: (typeof DICT)['pt'], iso: string | undefined) =>
189 iso === undefined ? '' : t.resetAt(clockOf(iso), untilOf(iso, Date.now()))
190
191async function usageNow($: EngineInterface) {
192 const t = DICT[await langNow($)]
193 const usage = await $.session.usage()
194 const win = (kind: string) => usage.rateLimits.find(r => r.kind === kind)
195 const five = win('five_hour')
196 const week = win('seven_day')
197 return {
198 context: usage.context.percent,
199 mana: five === undefined ? undefined : 100 - five.percentUsed,
200 manaAt: resetText(t, five?.resetsAt),
201 manaIn: untilOf(five?.resetsAt, Date.now()),
202 vigor: week === undefined ? undefined : 100 - week.percentUsed,
203 vigorAt: resetText(t, week?.resetsAt),
204 vigorIn: untilOf(week?.resetsAt, Date.now()),
205 }
206}
207
208/**
209 * A campanha é acompanhada sempre (a comemoração e a side quest leem dela); o
210 * módulo campanha decide se ela aparece no painel e se fica guardada no $.store.
211 */
212async function saveCampaign($: EngineInterface, c: Campaign) {
213 await update($, campaign, () => c)
214 if (await moduleOn($, 'campanha')) await $.store.set(storeKey(c.id), c)
215}
216
217async function sideQuestText($: EngineInterface) {
218 const c = await read($, campaign)
219 const list = reports(c)
220 return briefOf({ campaign: c, quests: await read($, quests), inventory: await read($, inventory), last: list[list.length - 1] }, await langNow($))
221}
222
223async function compactNow($: EngineInterface) {
224 const t = DICT[await langNow($)]
225 if ((await read($, working)) !== null) {
226 $.ui.toast(t.compactBusy)
227 return
228 }
229 try {
230 const r = await $.session.compact({ instructions: compactInstruction(await langNow($), await read($, campaign), await read($, quests)) })
231 await dropCard($, 'compactar')
232 if ('skip' in r) return
233 const k = (n: number | undefined) => (n === undefined ? '—' : `${Math.round(n / 1000)}k`)
234 $.ui.toast(t.compactDone(k(r.tokensBefore), k(r.tokensAfter)))
235 } catch {
236 $.ui.toast(t.compactBusy)
237 }
238}
239
240/** Para onde o grimório pode gravar agora: a pasta do vault e o ai-memory, se o MCP dele estiver conectado. */
241async function grimDests($: EngineInterface): Promise<Dests> {
242 return { vault: paths.vault, inbox: paths.inbox, memory: memoryTool(await $.tool.list().catch(() => [])) }
243}
244
245/** Grava uma inscrição num destino. Nota nova nunca sobrescreve outra: ganha -2, -3… */
246async function writeOne($: EngineInterface, w: Write, memory: Memory | undefined, used: Set<string>, heading: string) {
247 if (w.dest === 'aiMemory') {
248 let p = w.path
249 for (let n = 2; used.has(p); n++) p = withSuffix(w.path, n)
250 used.add(p)
251 const r = await $.mcp.call(memory!.server, 'memory_write_page', { path: p, body: w.text, ...(w.scope ? { scope: w.scope } : {}) })
252 if (r.isError) throw new Error(r.content.map(c => ('text' in c ? String(c.text) : '')).join(' ').slice(0, 200))
253 return
254 }
255 if (w.append) {
256 const current = (await $.fs.exists(w.path)) ? await $.fs.read(w.path) : ''
257 await $.fs.write(w.path, appendDecision(current, w.text, heading))
258 return
259 }
260 // ponytail: exists→write não é atômico; duas sessões selando no mesmo instante podem colidir.
261 let p = w.path
262 for (let n = 2; used.has(p) || (await $.fs.exists(p)); n++) p = withSuffix(w.path, n)
263 used.add(p)
264 await $.fs.write(p, w.text)
265}
266
267/**
268 * Grava as inscrições pendentes nos destinos marcados. Nada sai daqui sem o
269 * clique em Selar. Uma inscrição só sai da lista quando todos os destinos
270 * marcados gravaram. Se só parte gravou, ela continua pendente só com os que
271 * faltam (indisponíveis ou com falha), para o próximo Selar tentar de novo.
272 */
273async function seal($: EngineInterface) {
274 // Dois cliques seguidos não gravam duas vezes.
275 if (sealing) return
276 sealing = true
277 try {
278 const t = DICT[await langNow($)]
279 // Inscrição sem nenhum destino marcado fica onde está, sem aviso de falha: não havia o que gravar.
280 const list = pending(await read($, inscriptions)).filter(i => i.destinations.length > 0)
281 if (list.length === 0) {
282 if (pending(await read($, inscriptions)).length > 0) $.ui.toast(t.grimNoneMarked)
283 return
284 }
285 const repo = await $.session.cwd()
286 const found = await grimDests($)
287 // O vault e a pasta de entrada podem vir do /config sem passar pelos Primeiros passos:
288 // confere antes de gravar. Uma pasta de entrada que sai do vault desliga o destino.
289 const inbox = found.inbox ?? 'Inbox'
290 let vault = ''
291 if (found.vault !== '' && !isSubfolder(inbox)) $.ui.toast(t.inboxInvalid(inbox))
292 else if (found.vault !== '') vault = (await checkedFolder($, found.vault)) ?? ''
293 const dests = { ...found, vault }
294 const used = new Set<string>()
295 const missed: string[] = []
296 const sealed: Record<string, Destination[]> = {}
297 const left: Record<string, Destination[]> = {}
298 for (const i of list) {
299 const writes = writesFor(i, { repo, ...dests }, Date.now())
300 const wantsRepo = i.destinations.some(d => d === 'claudeMd' || d === 'agentsMd')
301 if (wantsRepo && !writes.some(w => w.dest === 'claudeMd' || w.dest === 'agentsMd')) $.ui.toast(t.privacyBlocked)
302 const ok: Destination[] = []
303 const failed: Destination[] = []
304 for (const w of writes) {
305 try {
306 await writeOne($, w, dests.memory, used, t.decisionsHeading)
307 ok.push(w.dest)
308 } catch (err) {
309 failed.push(w.dest)
310 missed.push(`${t.dest[w.dest]}: ${err instanceof Error ? err.message : String(err)}`)
311 }
312 }
313 // O que ficou por gravar: o que falhou e o destino marcado que agora está sem pasta ou sem MCP.
314 // Tudo o que está marcado foi visto pela pessoa (a sugestão nasce só com o disponível).
315 // O que a regra de privacidade barrou não volta: o aviso já disse por quê.
316 const rest = [...failed, ...i.destinations.filter(d => !available(d, dests))]
317 if (ok.length > 0 && rest.length === 0) sealed[i.id] = ok
318 else if (ok.length > 0) left[i.id] = rest
319 }
320 const done = Object.keys(sealed).length
321 if (done > 0) $.ui.toast(t.sealed(done))
322 if (done < list.length || missed.length > 0) $.ui.toast(t.notSealed(list.length - done, missed.join('; ')))
323 await update($, inscriptions, all =>
324 all.map(i => (sealed[i.id] !== undefined ? { ...i, sealed: sealed[i.id] } : left[i.id] !== undefined ? { ...i, destinations: left[i.id] } : i)),
325 )
326 if (pending(await read($, inscriptions)).length === 0) await dropCard($, 'grimorio')
327 } finally {
328 sealing = false
329 }
330}
331
332async function discard($: EngineInterface) {
333 await update($, inscriptions, all => all.filter(i => i.sealed !== undefined))
334 await dropCard($, 'grimorio')
335}
336
337/** Haiku: só no clique em "Resumir com Haiku" (o botão aparece com o ⚙ ligado). Gasta tokens. */
338async function rewriteWithHaiku($: EngineInterface) {
339 const l = await langNow($)
340 const out: Inscription[] = []
341 for (const i of pending(await read($, inscriptions))) {
342 try {
343 const r = await $.model.complete({ model: 'haiku', prompt: haikuPrompt(i, l), maxTokens: 120 })
344 out.push(r.isAnswered && r.text.trim() !== '' ? { ...i, text: r.text.trim().replace(/^"|"$/g, '') } : i)
345 } catch {
346 out.push(i)
347 }
348 }
349 const byId = new Map(out.map(i => [i.id, i]))
350 await update($, inscriptions, all => all.map(i => byId.get(i.id) ?? i))
351 $.ui.toast(DICT[l].haikuDone(out.length))
352}
353
354/**
355 * ✦ Recalibrar objetivo: o Haiku lê os pedidos da pessoa nesta sessão (o 1º e os
356 * capítulos) e devolve o objetivo atual em poucas palavras. Só roda no clique.
357 */
358async function recalibrateGoal($: EngineInterface) {
359 const l = await langNow($)
360 const t = DICT[l]
361 const c = await read($, campaign)
362 if (c === null) return
363 const asks = [c.objective, ...c.chapters.slice(1).map(ch => ch.prompt)].slice(-10)
364 try {
365 const r = await $.model.complete({ model: 'haiku', prompt: t.recalibratePrompt(asks), maxTokens: 60 })
366 const text = r.isAnswered ? (r.text.trim().replace(/^["“']|["”'.]$/g, '').split(/\r?\n/)[0]?.trim() ?? '') : ''
367 if (text === '') {
368 $.ui.toast(t.recalibrateFail)
369 return
370 }
371 const cur = await read($, campaign)
372 if (cur !== null) await saveCampaign($, { ...cur, short: cutAtWord(text, 70), shortFrom: 'haiku' })
373 $.ui.toast(t.recalibrateDone(cutAtWord(text, 70)))
374 } catch {
375 $.ui.toast(t.recalibrateFail)
376 }
377}
378
379/** O guarda nos modos diálogo e chefe: põe os arquivos no estado, pergunta e limpa. */
380async function askGuard($: EngineInterface, files: GuardFile[], mode: 'dialogo' | 'chefe') {
381 const l = await langNow($)
382 const t = DICT[l]
383 const labels = LABELS[l][mode]
384 const names = files.map(f => f.name).join(', ')
385 await update($, guard, () => files)
386 try {
387 const answer = await $.ui.ask(mode === 'chefe' ? t.bossQuestion(names) : t.guardQuestion(names), {
388 header: mode === 'chefe' ? t.bossHeader : t.guardHeader,
389 options: labels,
390 })
391 return verdictOf(answer, labels)
392 } catch {
393 // Dispensado, ou ninguém para responder (claude -p): nos dois casos, não libera.
394 return 'sem-resposta' as const
395 } finally {
396 await update($, guard, () => [])
397 }
398}
399
400/**
401 * Decide uma escrita em arquivo protegido. Sempre pergunta (o `$.ui.ask` rejeita
402 * sozinho onde não há ninguém); `interactive` só escolhe a mensagem da recusa.
403 */
404async function guardCall($: EngineInterface, tool: string, toolUseId: string, files: GuardFile[]) {
405 const t = DICT[await langNow($)]
406 const names = files.map(f => f.name).join(', ')
407 const mode = (await prefsNow($)).guardMode
408 if (mode === 'nativo') {
409 $.ui.notice(toolUseId, t.guardNotice(names))
410 return undefined
411 }
412 const verdict = await askGuard($, files, mode)
413 if (verdict === 'liberar') return undefined
414 if (verdict === 'sem-resposta' && !interactive) return { deny: t.denyHeadless(names) }
415 if (verdict === 'negociar') {
416 await fillOrCopy($, t.negotiateDraft(names))
417 return { deny: t.denyNegotiate(names) }
418 }
419 const turn = await read($, turnId)
420 await update($, refusals, list => [...list, { turnId: turn, text: `${names} (${tool})` }])
421 return { deny: t.denyRefuse(names) }
422}
423
424/** Guarda os projetos no estado e no $.store (valem para todas as sessões). */
425async function saveProjects($: EngineInterface, change: (list: Project[]) => Project[]) {
426 // Lê o $.store na hora: outra sessão aberta pode ter criado projetos desde o início desta.
427 const stored = await $.store.get('projetos')
428 const next = change(stored === undefined ? [...(await read($, projects))] : loadProjects(stored))
429 await update($, projects, () => next)
430 await $.store.set('projetos', next)
431}
432
433/** As pastas que o Claude alcança além da pasta da sessão: as do settings e as do /add-dir. */
434async function accessDirs($: EngineInterface) {
435 const settings = await $.settings.read().catch(() => ({}))
436 return [...settingsDirs(settings), ...(await read($, sessionDirs))]
437}
438
439/** Acesso permanente: acrescenta as pastas sem acesso ao .claude/settings.local.json da pasta da sessão. */
440async function grantAlways($: EngineInterface, id: string) {
441 const t = DICT[await langNow($)]
442 const cwd = await $.session.cwd()
443 const p = (await read($, projects)).find(x => x.id === id)
444 if (p === undefined) return
445 const dirs = await accessDirs($)
446 const toAdd = p.folders.filter(f => !hasAccess(f, cwd, dirs))
447 if (toAdd.length === 0) return
448 const file = `${cwd.replace(/[\\/]+$/, '')}/.claude/settings.local.json`
449 try {
450 const current = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
451 await $.fs.write(file, withDirectories(current, toAdd))
452 $.ui.toast(t.accessSaved(toAdd.length))
453 } catch (err) {
454 $.ui.toast(t.accessFailed(err instanceof Error ? err.message : String(err)))
455 }
456}
457
458/**
459 * Uma pasta digitada: só entra se for uma pasta que existe, e entra pelo caminho
460 * absoluto (um relativo mudaria de sentido em outra sessão). Caminho de rede o
461 * $.fs não alcança: a pessoa recebe o aviso em vez de nada.
462 */
463async function checkedFolder($: EngineInterface, raw: string) {
464 const t = DICT[await langNow($)]
465 const folder = raw.trim().replace(/^"|"$/g, '')
466 if (folder === '') return undefined
467 if (/^[\\/]{2}/.test(folder)) {
468 $.ui.toast(t.folderNetwork(folder))
469 return undefined
470 }
471 const stat = await $.fs.stat(folder, { resolve: true }).catch(() => undefined)
472 if (stat?.kind === 'dir') return stat.realPath ?? folder
473 $.ui.toast(t.folderMissing(folder))
474 return undefined
475}
476
477async function addCwdProject($: EngineInterface) {
478 const cwd = await $.session.cwd()
479 if (currentProject(await read($, projects), cwd) !== undefined) return
480 await saveProjects($, l => addProject(l, baseFolderName(cwd), cwd))
481}
482
483/** O 📖 do rodapé e o /grimorio: com inscrições pendentes, o cartão do Mago sobe acima do prompt. */
484async function callMage($: EngineInterface) {
485 if (pending(await read($, inscriptions)).length === 0) return false
486 await pushCard($, 'grimorio')
487 return true
488}
489
490let readingTasks = false
491
492/**
493 * As quests vêm da lista de tarefas do Claude. Lê o TaskList (de graça, sem o
494 * modelo) ao iniciar e no fim de cada turno: uma sessão retomada ou um mod
495 * recarregado não perde as tarefas abertas. Só roda se o motor liberar sem perguntar.
496 */
497async function refreshTasks($: EngineInterface) {
498 try {
499 if (!(await $.tool.list()).some(t => t.name === 'TaskList')) return
500 const check = await $.tool.check({ tool: 'TaskList' } as never)
501 if ((check as { decision?: string }).decision !== 'allow') return
502 readingTasks = true
503 const ran = (await $.tool.call({ tool: 'TaskList' } as never)) as { result?: { tasks?: { id?: unknown; subject?: unknown; status?: unknown }[] } }
504 const tasks = ran.result?.tasks ?? []
505 // Lista vazia: as tarefas foram concluídas e apagadas. Saem as quests que vieram
506 // dela; as do TodoWrite (id "todo-N") ficam, porque o TaskList não as conhece.
507 if (tasks.length === 0) {
508 await update($, quests, list => list.filter(q => q.id.startsWith('todo-')))
509 return
510 }
511 await update($, quests, () =>
512 tasks
513 .filter(t => typeof t.id === 'string' && typeof t.subject === 'string')
514 .map(t => ({
515 id: String(t.id),
516 title: String(t.subject),
517 status: t.status === 'completed' || t.status === 'in_progress' ? t.status : ('pending' as const),
518 })),
519 )
520 } catch {
521 // Sem TaskList (versão antiga) ou sem permissão: as quests seguem pelo TaskCreate/TodoWrite.
522 } finally {
523 readingTasks = false
524 }
525}
526
527/**
528 * No Windows não há LANG: a língua do sistema vem do Get-Culture ("pt-BR").
529 * Sem o PowerShell (Mac, Linux), devolve undefined e a língua sai do 1º pedido.
530 */
531async function windowsCulture($: EngineInterface): Promise<string | undefined> {
532 try {
533 const r = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', '(Get-Culture).Name'], { timeoutMs: 3000 })
534 const name = r.stdout.trim()
535 return r.exitCode === 0 && /^[a-z]{2}(-[A-Za-z]{2,})?$/.test(name) ? name : undefined
536 } catch {
537 return undefined
538 }
539}
540
541/** Linhas do painel por px de Svg (o desktop desenha uma linha de texto em ~19 px). */
542const PX_PER_ROW = 19
543
544/**
545 * Quantas linhas a tela principal ocupa, pela altura conhecida de cada arte.
546 * Estimativa com folga: serve só para decidir se o rodapé pode ficar preso.
547 */
548async function mainRows($: EngineInterface, cols: number) {
549 const rows = (px: number) => Math.ceil(px / PX_PER_ROW)
550 const c = await read($, campaign)
551 const q = questView(await read($, quests))
552 const members = await read($, party)
553 const inv = await read($, inventory)
554 const tab = await read($, partyTab)
555 const width = Math.max(10, cols - 2)
556 const goal = c === null ? 1 : Math.ceil(goalOf(c).length / width)
557 const objective = 1 + goal + rows(36) + (c === null ? 0 : 2)
558 const questsPx = q.current === undefined && q.done.length === 0 ? PX_PER_ROW : (q.done.length > 0 ? 28 : 0) + 48 + q.next.length * 24 + (q.mist > 0 ? 18 : 0)
559 const questRows = 1 + rows(questsPx) + (q.current === undefined && q.done.length === 0 ? 2 : 0)
560 const active = Math.min(3, members.filter(m => m.status === 'run').length)
561 const partyRows = rows((1 + active) * 62 + (members.length > active ? 30 : 0))
562 const invRows = 2 + rows(Math.max(1, Math.min(8, inv.items.length || (inv.recent ?? []).length)) * 24)
563 const footer = rows(24) + 1
564 return objective + questRows + 2 + (tab === 'grupo' ? partyRows : invRows) + footer + 4
565}
566
567/** O ◈ da faixa: abre o painel se estiver fechado, fecha se estiver aberto. */
568async function togglePane($: EngineInterface) {
569 // Fecha só o que está à vista: um painel atrás de outra aba, ou aberto sem lugar, é trazido para frente.
570 const mine = (await $.ui.panes()).find(pane => pane.id === PANE)
571 if (mine !== undefined && mine.isShown && mine.isPlaced) {
572 await $.ui.close({ id: PANE })
573 return
574 }
575 if (!(await moduleOn($, 'painel'))) await savePrefs($, x => ({ ...x, modules: { ...x.modules, painel: true } }))
576 await refreshTheme($)
577 // Como o /hero: enquanto os Primeiros passos não forem concluídos, abre neles.
578 if ((await $.store.get('setupDone')) !== true) await update($, view, () => 'boasVindas' as const)
579 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
580}
581
582async function renderPane($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement> {
583 const ui = $.ui.resolve(e)
584 const c: Ctx = { ...(await ctxOf($, e.surface, e.props.bodyColumns)), ui }
585 const v = await read($, view)
586 const p = await prefsNow($)
587 const toMain = () => update($, view, () => 'main' as const)
588
589 if (v === 'sobre') return aboutView(c, toMain)
590 if (v === 'boasVindas') {
591 const cwd = await $.session.cwd()
592 return setupView(c, await read($, setup), langOption, await grimDests($), currentProject(await read($, projects), cwd) !== undefined, {
593 lang: async value => {
594 await $.config.set({ key: 'hero-journey.language', value })
595 },
596 answer: async yes => {
597 await update($, setup, s => ({ ...s, vault: yes ? ('sim' as const) : ('nao' as const), msg: undefined }))
598 if (!yes) await $.config.set({ key: 'hero-journey.vaultPath', value: '' })
599 },
600 path: async value => {
601 const folder = await checkedFolder($, value)
602 if (folder === undefined) {
603 await update($, setup, s => ({ ...s, msg: c.t.folderMissing(value) }))
604 return
605 }
606 await update($, setup, s => ({ ...s, msg: undefined }))
607 await $.config.set({ key: 'hero-journey.vaultPath', value: folder })
608 },
609 // A pasta de entrada é uma subpasta do vault que já existe. Vazia, volta ao padrão (Inbox).
610 inbox: async value => {
611 const sub = value.trim().replace(/^"|"$/g, '')
612 if (!isSubfolder(sub)) {
613 await update($, setup, s => ({ ...s, msg: c.t.inboxInvalid(sub) }))
614 return
615 }
616 // A barra do próprio vault: no Windows, C:\\Vault\\Inbox, sem misturar com /.
617 const inboxPath = `${paths.vault}${paths.vault.includes('\\') ? '\\' : '/'}${sub}`
618 if (sub !== '' && (await checkedFolder($, inboxPath)) === undefined) {
619 await update($, setup, s => ({ ...s, msg: c.t.folderMissing(inboxPath) }))
620 return
621 }
622 await update($, setup, s => ({ ...s, msg: undefined }))
623 await $.config.set({ key: 'hero-journey.vaultInbox', value: sub })
624 },
625 addProject: () => addCwdProject($),
626 done: async () => {
627 await $.store.set('setupDone', true)
628 await update($, view, () => 'main' as const)
629 },
630 })
631 }
632 if (v === 'projetos' && isOn(p, 'projetos')) {
633 const cwd = await $.session.cwd()
634 const list = await read($, projects)
635 return projectsView(c, list, cwd, await accessDirs($), currentProject(list, cwd), {
636 back: toMain,
637 addCwd: () => addCwdProject($),
638 create: name => saveProjects($, l => addProject(l, name)),
639 addFolder: async (id, value) => {
640 const folder = await checkedFolder($, value)
641 if (folder !== undefined) await saveProjects($, l => addFolder(l, id, folder))
642 },
643 removeFolder: (id, folder) => saveProjects($, l => removeFolder(l, id, folder)),
644 pin: id => saveProjects($, l => togglePin(l, id)),
645 confirming: await read($, confirmDel),
646 // ✕ pede confirmação: o primeiro clique arma, o segundo remove.
647 remove: async id => {
648 if ((await read($, confirmDel)) !== id) {
649 await update($, confirmDel, () => id)
650 return
651 }
652 await update($, confirmDel, () => '')
653 await saveProjects($, l => removeProject(l, id))
654 },
655 open: async id => {
656 const proj = (await read($, projects)).find(x => x.id === id)
657 if (proj !== undefined) await fillOrCopy($, openSessionPrompt(proj, await langNow($)))
658 },
659 accessNow: folder => fillOrCopy($, `/add-dir ${folder}`),
660 accessAlways: id => grantAlways($, id),
661 })
662 }
663 if (v === 'config') {
664 return configView(c, p, langOption, {
665 back: toMain,
666 lang: async value => {
667 const t = DICT[await langNow($)]
668 const r = await $.config.set({ key: 'hero-journey.language', value })
669 $.ui.toast('deny' in r && r.deny !== undefined ? t.langDenied(r.deny) : t.langChanged)
670 },
671 skin: skin => savePrefs($, x => ({ ...x, skin })),
672 profile: profile => savePrefs($, x => withProfile(x, profile)),
673 toggle: section => savePrefs($, x => toggleSection(x, section)),
674 diary: mode => savePrefs($, x => ({ ...x, diary: mode })),
675 module: async id => {
676 await savePrefs($, x => toggleModule(x, id))
677 // Desligar o painel no ⚙ fecha o painel aberto.
678 if (id === 'painel' && !(await moduleOn($, 'painel'))) await $.ui.close({ id: PANE })
679 },
680 guard: mode => savePrefs($, x => ({ ...x, guardMode: mode })),
681 haiku: () => savePrefs($, x => ({ ...x, grimorioHaiku: !x.grimorioHaiku })),
682 setup: () => update($, view, () => 'boasVindas' as const),
683 projects: () => update($, view, () => 'projetos' as const),
684 about: () => update($, view, () => 'sobre' as const),
685 compact: async () => {
686 if (await moduleOn($, 'mochila')) await pushCard($, 'compactar')
687 },
688 })
689 }
690 const all = await read($, inscriptions)
691 const grimActions = {
692 toggle: (id: string, dest: Destination) => update($, inscriptions, list => toggleDest(list, id, dest)),
693 seal: () => seal($),
694 discard: () => discard($),
695 haiku: p.grimorioHaiku ? () => rewriteWithHaiku($) : undefined,
696 }
697 if (v === 'grimorio' && isOn(p, 'grimorio')) {
698 return grimPage(c, pending(all), all.filter(i => i.sealed !== undefined), await grimDests($), toMain, grimActions)
699 }
700
701 const camp = await read($, campaign)
702
703 return mainView(
704 c,
705 {
706 prefs: p,
707 campaign: camp,
708 quests: await read($, quests),
709 party: await read($, party),
710 inventory: await read($, inventory),
711 partyTab: await read($, partyTab),
712 usage: await usageNow($),
713 working: await read($, working),
714 pendingInscriptions: pending(all).length,
715 },
716 {
717 tab: tab => update($, partyTab, () => tab),
718 joinParty: joined => savePrefs($, x => ({ ...x, partyJoined: joined })),
719 achieved: async () => {
720 const cur = await read($, campaign)
721 if (cur !== null) await saveCampaign($, { ...cur, achieved: true })
722 },
723 compact: async () => {
724 if (await moduleOn($, 'mochila')) await pushCard($, 'compactar')
725 else $.ui.toast(c.t.off(c.t.module.mochila[0]))
726 },
727 recalibrate: () => recalibrateGoal($),
728 grimoire: async () => {
729 if (!(await callMage($))) await update($, view, () => 'grimorio' as const)
730 },
731 sideQuest: () => pushCard($, 'sideQuest'),
732 projects: () => update($, view, () => 'projetos' as const),
733 config: () => update($, view, () => 'config' as const),
734 about: () => update($, view, () => 'sobre' as const),
735 },
736 )
737}
738
739
740export const register: Register = (on, options) => {
741 langOption = typeof options.language === 'string' ? options.language : 'auto'
742 isGuarded = makeGuard(listOption(options.protect), listOption(options.allow))
743 paths = {
744 vault: typeof options.vaultPath === 'string' ? options.vaultPath.trim() : '',
745 inbox: typeof options.vaultInbox === 'string' && options.vaultInbox.trim() !== '' ? options.vaultInbox.trim() : 'Inbox',
746 }
747
748 // ---------- Sessão, comandos e tema ----------
749
750 on('session.start', async ($, e, next) => {
751 const ran = await next(e)
752 interactive = e.isInteractive
753
754 await $.command.register({ name: 'hero', description: 'Abre o Hero Journey / Opens Hero Journey' })
755 await $.command.register({ name: 'side-quest', description: 'Prepara uma side quest (sessão paralela) / Prepares a side quest' })
756 await $.command.register({ name: 'grimorio', description: 'Abre o grimório / Opens the grimoire' })
757 await $.command.register({ name: 'projetos', description: 'Projetos e pastas com acesso / Projects and folders with access' })
758
759 // Cada etapa do início falha sozinha, sem levar as outras junto.
760 const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
761 const system =
762 (await $.env.get('LC_ALL').catch(() => undefined)) ?? (await $.env.get('LANG').catch(() => undefined)) ?? (await windowsCulture($))
763 // O átomo guarda sempre a resolução do `auto`; a língua fixada vence em `langNow`.
764 // Assim, voltar de pt-BR para auto no ⚙ cai na língua certa sem esperar outro início.
765 await update($, lang, () => resolveLang('auto', settings.language, system, undefined))
766 langFromPrompt = settings.language === undefined && system === undefined
767
768 await update($, prefs, () => migratePrefs(undefined))
769 const stored = await $.store.get('prefs')
770 if (stored !== undefined) await update($, prefs, () => migratePrefs(stored))
771
772 // Uma sessão retomada recupera a campanha guardada; o $.store guarda só as 20 mais recentes.
773 const id = await $.session.id().catch(() => '')
774 const mine = id === '' ? undefined : ((await $.store.get(storeKey(id))) as Campaign | undefined)
775 if (mine !== undefined && (await read($, campaign)) === null) await update($, campaign, () => mine)
776 const kept: { key: string; startedAt: number }[] = []
777 for (const key of await $.store.keys()) {
778 if (!key.startsWith('campanha:')) continue
779 const c = (await $.store.get(key)) as Campaign | undefined
780 kept.push({ key, startedAt: typeof c?.startedAt === 'number' ? c.startedAt : 0 })
781 }
782 for (const key of prune(kept)) await $.store.delete(key)
783
784 await update($, projects, () => loadProjects(undefined))
785 const savedProjects = await $.store.get('projetos')
786 if (savedProjects !== undefined) await update($, projects, () => loadProjects(savedProjects))
787 // Na primeira vez, o painel abre nos Primeiros passos (língua, vault, projeto).
788 if ((await $.store.get('setupDone')) !== true) await update($, view, () => 'boasVindas' as const)
789
790 await refreshTheme($)
791 await refreshInventory($)
792 await refreshTasks($)
793 // Aberto sem pedido, o painel só aparece em tela larga (144 colunas).
794 if (await moduleOn($, 'painel')) await $.ui.open({ id: PANE, title: PANE_TITLE })
795 return ran
796 })
797
798 on('command.run', { command: 'hero' }, async $ => {
799 const t = DICT[await langNow($)]
800 if (!(await moduleOn($, 'painel'))) await savePrefs($, x => ({ ...x, modules: { ...x.modules, painel: true } }))
801 await refreshTheme($)
802 // Enquanto os Primeiros passos não forem concluídos, o /hero abre neles (janela estreita incluída).
803 const firstRun = (await $.store.get('setupDone')) !== true
804 await update($, view, () => (firstRun ? ('boasVindas' as const) : ('main' as const)))
805 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
806 // Aberto, o painel fala por si: nenhuma linha no chat.
807 return opened.isPlaced ? {} : { text: t.paneNotPlaced(String(opened.reason)) }
808 })
809
810 on('command.run', { command: 'side-quest' }, async $ => {
811 const t = DICT[await langNow($)]
812 if (!(await moduleOn($, 'sideQuest'))) return { text: t.off(t.module.sideQuest[0]) }
813 await pushCard($, 'sideQuest')
814 return { text: `⇄ ${t.sqTitle}` }
815 })
816
817 on('command.run', { command: 'grimorio' }, async $ => {
818 const t = DICT[await langNow($)]
819 if (!(await moduleOn($, 'grimorio'))) return { text: t.off(t.module.grimorio[0]) }
820 // Com inscrições pendentes, o Mago aparece acima do prompt; sem elas, o histórico no painel.
821 if (await callMage($)) return { text: `📖 ${t.grimTitle}` }
822 if (!(await moduleOn($, 'painel'))) return { text: t.grimEmpty }
823 await update($, view, () => 'grimorio' as const)
824 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
825 return { text: `📖 ${t.grimTitle}` }
826 })
827
828 on('command.run', { command: 'projetos' }, async $ => {
829 const t = DICT[await langNow($)]
830 if (!(await moduleOn($, 'projetos'))) return { text: t.off(t.module.projetos[0]) }
831 if (!(await moduleOn($, 'painel'))) await savePrefs($, x => ({ ...x, modules: { ...x.modules, painel: true } }))
832 await update($, view, () => 'projetos' as const)
833 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
834 return { text: t.projects }
835 })
836
837 // Uma pasta liberada pelo /add-dir conta como acesso nesta sessão.
838 on('classic.DirectoryAdded', async ($, e, next) => {
839 const dir = e.directory
840 await update($, sessionDirs, list => (list.some(d => isInside(dir, d)) ? list : [...list, dir]))
841 return next(e)
842 }).catch(($, e, next) => next(e))
843
844 // O título que o app deu à sessão ("Claude HUD RPG handoff") é o melhor objetivo curto.
845 on('classic.UserPromptSubmit', async ($, e, next) => {
846 const ran = await next(e)
847 const title = (e as { session_title?: string; source?: string }).session_title?.trim()
848 const c = await read($, campaign)
849 const source = (e as { source?: string }).source
850 // Só de mensagem da pessoa, e sem apagar o objetivo que ela recalibrou com o Haiku.
851 if (source !== undefined && source !== 'user') return ran
852 if (c !== null && title !== undefined && title !== '' && c.short !== title && c.shortFrom !== 'haiku') await saveCampaign($, { ...c, short: title, shortFrom: 'app' })
853 return ran
854 }).catch(($, e, next) => next(e))
855
856 on('config.set', { key: 'theme' }, async ($, e, next) => {
857 const ran = await next(e)
858 await refreshTheme($)
859 $.ui.invalidate('ui.render')
860 return ran
861 }).catch(($, e, next) => next(e))
862
863 // ---------- Campanha: mensagens e turnos ----------
864
865 on('prompt.submit', async ($, e, next) => {
866 const text = e.text.trim()
867 // Aviso de subagente, gatilho agendado ou outra sessão não abre capítulo nem vira objetivo.
868 const kind = (e as { origin?: { kind?: string } }).origin?.kind
869 const fromPerson = kind === undefined || kind === 'composer' || kind === 'bridge'
870 if (fromPerson && text !== '' && !text.startsWith('/')) {
871 if (langFromPrompt) {
872 langFromPrompt = false
873 await update($, lang, () => (looksPortuguese(text) ? 'pt' : 'en'))
874 }
875 await saveCampaign($, onPrompt(await read($, campaign), await $.session.id(), text, Date.now()))
876 // Durante a missão o relatório fica recolhido; ele abre sozinho no fim.
877 await update($, reportOpen, () => false)
878 }
879 return next(e)
880 }).catch(($, e, next) => next(e))
881
882 on('turn.start', async ($, e, next) => {
883 const ran = await next(e)
884 await update($, turnId, () => ran.turnId)
885 await update($, intent, () => '')
886 turnPrompts.set(ran.turnId, e.text)
887 if (turnPrompts.size > 50) turnPrompts.delete(turnPrompts.keys().next().value ?? '')
888 const c = await read($, campaign)
889 if (c !== null) {
890 const linked = assignTurn(c, ran.turnId, e.text)
891 if (linked !== c) await saveCampaign($, linked)
892 }
893 return ran
894 })
895
896 // A intenção: o texto que o Claude escreve antes de chamar as ferramentas
897 // (no estilo briefing-de-bordo, uma frase com o verbo na frente) vai para a faixa.
898 on('turn.step', async function* ($, e, next) {
899 const r = yield* next(e)
900 if (e.agentId === undefined && r.toolUses.length > 0 && r.answer.trim() !== '') {
901 const said = r.answer.replace(/\s+/g, ' ').trim()
902 const first = said.split(/(?<=[.!?])\s/)[0] ?? said
903 await update($, intent, () => (first.length > 160 ? `${first.slice(0, 159)}…` : first))
904 }
905 return r
906 })
907
908 // ---------- Grupo: subagentes (padrão do agent-flow) ----------
909
910 on('agent.spawn', async ($, e, next) => {
911 const r = await next(e)
912 if ('agentId' in r && typeof r.agentId === 'string') {
913 const agentId = r.agentId
914 await update($, party, list =>
915 upsertMember(list, {
916 id: e.tool_use_id,
917 cls: agentClass(e.subagentType, e.description),
918 description: e.description,
919 type: e.subagentType,
920 status: 'run',
921 agentId,
922 }),
923 )
924 }
925 return r
926 }).catch(($, e, next) => next(e))
927
928 // ---------- Diário, faixa, quests e inventário: toda chamada de ferramenta ----------
929
930 on('tool.call', async ($, e, next) => {
931 // A leitura do TaskList que o próprio mod faz não é ação do Claude: nem diário, nem faixa.
932 if (readingTasks && e.tool === 'TaskList') return next(e)
933 const input = e as unknown as Record<string, unknown>
934 const isMain = e.agentId === undefined
935 const cls = classOf(e.tool, input)
936 const summary = summaryOf(e.tool, input)
937 const id = e.tool_use_id ?? `${e.tool}-${Date.now()}`
938
939 if (isMain) {
940 const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
941 await update($, working, () => ({ tool: e.tool, cls, summary, file }))
942 const turn = await read($, turnId)
943 const open = await read($, expanded)
944 await update($, diary, list => {
945 const entry: DiaryEntry = { id, tool: e.tool, cls, summary, turnId: turn, group: groupFor(list, cls, turn, id, open), status: 'run', at: Date.now() }
946 return [...list, entry].slice(-300)
947 })
948 }
949 if (e.tool === 'TodoWrite') {
950 const fromTodos = questsFromTodos(input.todos)
951 if (fromTodos !== undefined) await update($, quests, () => fromTodos)
952 }
953 if (AGENT.test(e.tool)) {
954 const description = String(input.description ?? '')
955 const type = String(input.subagent_type ?? 'general-purpose')
956 await update($, party, list => {
957 const known = list.find(m => m.id === id)
958 return upsertMember(list, { ...(known ?? {}), id, cls: agentClass(type, description), description, type, status: 'run' })
959 })
960 }
961
962 const started = Date.now()
963 const ran = await next(e)
964 const failed = ran.deny !== undefined || ran.isError === true
965 const result = (ran.deny === undefined ? ran.result : undefined) as Record<string, unknown> | undefined
966
967 if (isMain) {
968 const stats = patchStats(result?.structuredPatch)
969 await update($, diary, list =>
970 list.map(entry =>
971 entry.id === id
972 ? {
973 ...entry,
974 status: failed ? ('err' as const) : ('ok' as const),
975 ms: Date.now() - started,
976 isNew: result?.type === 'create' ? true : undefined,
977 ...(stats === undefined ? {} : stats),
978 }
979 : entry,
980 ),
981 )
982 }
983 if (e.tool === 'TaskCreate' && !failed) {
984 const task = (result?.task ?? {}) as { id?: unknown; subject?: unknown }
985 if (typeof task.id === 'string') {
986 const taskId = task.id
987 const subject = typeof task.subject === 'string' ? task.subject : String(input.subject ?? '')
988 await update($, quests, list => [...list.filter(q => q.id !== taskId), { id: taskId, title: subject, status: 'pending' as const }])
989 }
990 }
991 if (e.tool === 'TaskUpdate' && !failed && result?.success !== false) {
992 const taskId = String(input.taskId ?? '')
993 const status = input.status
994 await update($, quests, list =>
995 status === 'deleted'
996 ? list.filter(q => q.id !== taskId)
997 : list.map(q =>
998 q.id === taskId
999 ? {
1000 ...q,
1001 title: typeof input.subject === 'string' ? input.subject : q.title,
1002 status: status === 'completed' || status === 'in_progress' || status === 'pending' ? status : q.status,
1003 }
1004 : q,
1005 ),
1006 )
1007 }
1008 if (AGENT.test(e.tool)) {
1009 const agentId = typeof result?.agentId === 'string' ? result.agentId : undefined
1010 const background = input.run_in_background === true && !failed
1011 await update($, party, list =>
1012 list.map(m => (m.id === id ? { ...m, agentId: agentId ?? m.agentId, status: failed ? 'err' : background ? 'run' : 'ok' } : m)),
1013 )
1014 }
1015 // Só depois de uma escrita que deu certo; o resto (Bash incluído) espera o fim do turno.
1016 if (isMain && !failed && WRITES.test(e.tool)) await refreshInventory($)
1017 return ran
1018 }).catch(($, e, next) => next(e))
1019
1020 // ---------- Guarda dos protegidos (protected-paths-guard + large-edit-confirmation) ----------
1021
1022 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
1023 if (!(await moduleOn($, 'guarda'))) return next(e)
1024 const input = e as unknown as Record<string, unknown>
1025 const path = String(input.file_path ?? input.notebook_path ?? '')
1026 if (!isGuarded(path)) return next(e)
1027 const file = diffOf(path, { old_string: input.old_string, new_string: input.new_string ?? input.new_source, content: input.content })
1028 const denied = await guardCall($, e.tool, e.tool_use_id ?? '', [file])
1029 return denied ?? next(e)
1030 }).catch(async ($, e, next) => {
1031 // A edição já rodou depois de aprovada: devolve o resultado (sem rodar de novo).
1032 if (next.called) {
1033 try {
1034 return await next(e)
1035 } catch {
1036 return { deny: `${DICT.pt.denyFailed} / ${DICT.en.denyFailed}` }
1037 }
1038 }
1039 // Uma checagem que falhou nunca deixa a edição passar (falha fechada).
1040 return { deny: `${DICT.pt.denyFailed} / ${DICT.en.denyFailed}` }
1041 })
1042
1043 // Comandos de terminal que escreveriam um protegido (echo >> .env, sed -i, rm, Set-Content...).
1044 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
1045 if (!(await moduleOn($, 'guarda'))) return next(e)
1046 const command = String((e as unknown as Record<string, unknown>).command ?? '')
1047 const targets = shellTargets(command, isGuarded)
1048 if (targets.length === 0) return next(e)
1049 const files: GuardFile[] = targets.map(path => {
1050 const name = path.split(/[\\/]/).pop() ?? path
1051 return { name, path, added: 0, removed: 0, diff: `--- a/${name}\n+++ b/${name}\n@@ -0,0 +1,1 @@\n+$ ${command.slice(0, 400)}` }
1052 })
1053 const denied = await guardCall($, e.tool, e.tool_use_id ?? '', files)
1054 return denied ?? next(e)
1055 }).catch(async ($, e, next) => {
1056 if (next.called) {
1057 try {
1058 return await next(e)
1059 } catch {
1060 return { deny: `${DICT.pt.denyFailed} / ${DICT.en.denyFailed}` }
1061 }
1062 }
1063 return { deny: `${DICT.pt.denyFailed} / ${DICT.en.denyFailed}` }
1064 })
1065
1066 // ---------- Fim do turno: relatório, comemoração, grimório ----------
1067
1068 on('turn.complete', async ($, e, next) => {
1069 const ran = await next(e)
1070 if (e.agentId !== undefined) {
1071 const agentId = e.agentId
1072 await update($, party, list => list.map(m => (m.agentId === agentId ? { ...m, status: e.reason === 'answer' ? 'ok' : 'err' } : m)))
1073 return ran
1074 }
1075 await update($, working, () => null)
1076 await refreshInventory($)
1077 await refreshTasks($)
1078 const p = await prefsNow($)
1079 const l = await langNow($)
1080 const answer = e.answer ?? ''
1081 const said = turnPrompts.get(e.turnId) ?? ''
1082
1083 // O relatório é do capítulo deste turno (casado pelo turnId): uma mensagem
1084 // digitada no meio do turno não rouba o relatório, e um turno de continuação
1085 // não sobrescreve um relatório já aprovado.
1086 const c = await read($, campaign)
1087 const ch = chapterOf(c, e.turnId)
1088 if (c !== null && ch !== undefined && ch.report === undefined) {
1089 const entries = (await read($, diary)).filter(x => x.turnId === e.turnId)
1090 const refused = (await read($, refusals)).filter(x => x.turnId === e.turnId).map(x => x.text)
1091 const report = buildReport(ch, entries, answer, e.durationMs, e.reason, refused)
1092 const closed0 = closeChapter(c, report)
1093 // Sem título da sessão, a Missão da 1ª resposta vira o objetivo curto.
1094 const closed = closed0.short === undefined && ch.n === 1 && report.mission !== undefined ? { ...closed0, short: report.mission, shortFrom: 'missao' as const } : closed0
1095 await saveCampaign($, closed)
1096 await update($, reportAt, () => reports(closed).length - 1)
1097 await update($, reportOpen, () => isOn(p, 'campanha'))
1098 if (isOn(p, 'comemoracao')) {
1099 const text = celebrate(report, l)
1100 if (text !== undefined) $.ui.toast(text)
1101 }
1102 }
1103 await update($, refusals, list => list.filter(x => x.turnId !== e.turnId))
1104
1105 // O grimório só sugere; o Haiku, se ligado no ⚙, roda no clique do botão do cartão.
1106 if (isOn(p, 'grimorio') && e.reason === 'answer') {
1107 // A sugestão nasce só com o que dá para gravar agora: assim tudo o que está marcado
1108 // foi visto pela pessoa, e um destino marcado que cair depois vira pendência com aviso.
1109 const dests = await grimDests($)
1110 const fresh = detect(answer, said, e.turnId).map(i => ({ ...i, destinations: i.destinations.filter(d => available(d, dests)) }))
1111 if (fresh.length > 0) {
1112 // Guarda em silêncio: o contador do rodapé avisa, e o Mago aparece quando a pessoa abre o grimório.
1113 await update($, inscriptions, list => merge(list, fresh))
1114 }
1115 }
1116 $.ui.invalidate('ui.render')
1117 return ran
1118 })
1119
1120 // ---------- Mochila: o contexto enche ----------
1121
1122 on('session.measure', async ($, e, next) => {
1123 const ran = await next(e)
1124 if (await moduleOn($, 'mochila')) {
1125 const step = shouldWarn(e.context.percent ?? 0, warned)
1126 if (step === 'warn') {
1127 warned = true
1128 await pushCard($, 'compactar')
1129 } else if (step === 'reset') warned = false
1130 }
1131 $.ui.invalidate('ui.render')
1132 return ran
1133 })
1134
1135 // ---------- Desenho ----------
1136
1137 // O painel usa o fundo do tema da pessoa. Na tela principal ele ocupa a altura
1138 // toda, para o rodapé ficar preso embaixo.
1139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1140 const body = await renderPane($, e)
1141 if (e.surface !== 'desktop') return body
1142 const { Box } = $.ui.resolve(e)
1143 // A tela principal (ou a que cai nela com o módulo desligado) ocupa a altura toda.
1144 const v = await read($, view)
1145 const p = await prefsNow($)
1146 const main = v === 'main' || (v === 'projetos' && !isOn(p, 'projetos')) || (v === 'grimorio' && !isOn(p, 'grimorio'))
1147 // O motor não tem rolagem dentro do painel: o rodapé só fica preso quando tudo cabe.
1148 // Se não couber, o painel rola inteiro e nada fica escondido.
1149 const full = main && (await mainRows($, e.props.bodyColumns)) <= e.props.scroll.bodyRows
1150 return (
1151 <Box flexDirection="column" paddingX={1} height={full ? e.props.scroll.bodyRows : undefined}>
1152 {body}
1153 </Box>
1154 )
1155 })
1156
1157 // Faixa e cartões acima do prompt: uma instância só, compartilhada.
1158 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1159 if (e.props.hasSurvey) return next(e)
1160 const p = await prefsNow($)
1161 const now = await read($, working)
1162 const queue = (await read($, cards)).filter(card =>
1163 card === 'grimorio' ? isOn(p, 'grimorio') : card === 'compactar' ? isOn(p, 'mochila') : isOn(p, 'sideQuest'),
1164 )
1165 const camp = await read($, campaign)
1166 // A faixa fica enquanto houver campanha (o objetivo em miniatura); o pet age só durante o turno.
1167 const away = (await read($, party)).filter(m => m.status === 'run')
1168 const showBand = isOn(p, 'faixa') && ((e.props.isWorking && now !== null) || camp !== null || away.length > 0)
1169 const card = queue[0]
1170 if (!showBand && card === undefined) return next(e)
1171
1172 const ui = $.ui.resolve(e)
1173 const c: Ctx = { ...(await ctxOf($, e.surface, e.props.bodyColumns)), ui }
1174 const { Box } = ui
1175 const below = await next(e)
1176
1177 let cardTree: RenderElement | null = null
1178 if (card === 'compactar') {
1179 const usage = await usageNow($)
1180 const instruction = compactInstruction(await langNow($), camp, await read($, quests))
1181 cardTree = compactCard(c, usage.context === undefined ? `${COMPACT_AT}%+` : `${Math.round(usage.context)}%`, instruction, e.props.isWorking, {
1182 fill: async () => {
1183 if (await fillOrCopy($, `/compact ${instruction}`)) await dropCard($, 'compactar')
1184 },
1185 now: () => compactNow($),
1186 dismiss: () => dropCard($, 'compactar'),
1187 })
1188 } else if (card === 'grimorio') {
1189 cardTree = grimCard(c, pending(await read($, inscriptions)), await grimDests($), {
1190 toggle: (id, dest) => update($, inscriptions, list => toggleDest(list, id, dest)),
1191 seal: () => seal($),
1192 discard: () => discard($),
1193 haiku: p.grimorioHaiku ? () => rewriteWithHaiku($) : undefined,
1194 })
1195 } else if (card === 'sideQuest') {
1196 const brief = await sideQuestText($)
1197 const text = sideQuestPrompt(brief, await langNow($))
1198 cardTree = sideQuestCard(c, brief, {
1199 fill: async () => {
1200 await fillOrCopy($, text)hooks/core/classes.ts 137 lines1// Que classe faz cada coisa e como resumir uma chamada. Regras puras, sem `$`.
2
3import type { Cls } from '../../types'
4
5const has = (re: RegExp, text: unknown) => typeof text === 'string' && re.test(text)
6
7const TEST = /\b(test|tests|jest|vitest|karma|pytest|xunit|nunit|lint|eslint|tsc\b|prettier|ng test|dotnet test|playwright|cypress)\b/i
8const PIPELINE = /\b(az pipelines|az devops|gh run|gh workflow|docker|kubectl|helm|logs?\b|tail -f|journalctl|terraform)\b/i
9const DB = /\b(psql|sqlcmd|mysql|sqlite3?|mongo(sh)?|redis-cli|dotnet ef|prisma|migrat\w*|flyway|liquibase)\b/i
10const PR = /\b(git (commit|push|merge|rebase|tag)|gh pr|az repos pr)\b/i
11const HTTP = /\b(curl|wget|http(ie)?|grpcurl|az rest)\b/i
12const UI_FILE = /\.(html|css|scss|sass|less|svg|figma)$|\.component\.(ts|html|scss)$|\.(tsx|jsx|vue|svelte)$/i
13const DB_FILE = /\.(sql|prisma)$|[\\/]migrations?[\\/]/i
14const PIPE_FILE = /pipelines?.*\.ya?ml$|[\\/]\.github[\\/]workflows[\\/]|dockerfile$|\.tf$/i
15const TEST_FILE = /\.(spec|test)\.\w+$|[\\/](tests?|__tests__)[\\/]/i
16
17/**
18 * A classe de uma chamada de ferramenta. Segue a tabela do handoff: Mago
19 * pesquisa, Ilusionista desenha, Artífice codifica, Paladino testa,
20 * Patrulheiro cuida de pipeline e logs, Anão do banco, Arauto de PR e merge,
21 * Alquimista de integrações. O resto fica com o Clawd druida.
22 */
23export const classOf = (tool: string, input: Record<string, unknown>): Cls => {
24 if (/^mcp__/.test(tool)) return /pull_request|merge|review/i.test(tool) ? 'arauto' : 'alquimista'
25 if (/^(Read|Grep|Glob|LS|WebFetch|WebSearch|ToolSearch|NotebookRead)$/.test(tool)) return 'mago'
26 if (tool === 'Agent' || tool === 'Task') return agentClass(String(input.subagent_type ?? ''), String(input.description ?? ''))
27 if (/^(TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|EnterPlanMode|ExitPlanMode)$/.test(tool)) return 'druida'
28 if (tool === 'Bash' || tool === 'PowerShell') {
29 const cmd = `${String(input.command ?? '')} ${String(input.description ?? '')}`
30 if (TEST.test(cmd)) return 'paladino'
31 if (PR.test(cmd)) return 'arauto'
32 if (DB.test(cmd)) return 'anao'
33 if (PIPELINE.test(cmd)) return 'patrulheiro'
34 if (HTTP.test(cmd)) return 'alquimista'
35 return 'artifice'
36 }
37 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) {
38 const path = String(input.file_path ?? input.notebook_path ?? '')
39 if (has(PIPE_FILE, path)) return 'patrulheiro'
40 if (has(DB_FILE, path)) return 'anao'
41 if (has(TEST_FILE, path)) return 'paladino'
42 if (has(UI_FILE, path)) return 'ilusionista'
43 return 'artifice'
44 }
45 return 'druida'
46}
47
48/** A classe de um subagente pelo tipo e pela descrição. */
49export const agentClass = (type: string, description: string): Cls => {
50 const text = `${type} ${description}`
51 if (/explore|research|pesquis|context|search|busca/i.test(text)) return 'mago'
52 if (/plan|architect|design|\bui\b|\bux\b|prototip|protot/i.test(text)) return 'ilusionista'
53 if (/\bqa\b|review|revis|test|lint|audit/i.test(text)) return 'paladino'
54 if (/pipeline|deploy|\blogs?\b|\bci\b|\bbuild\b/i.test(text)) return 'patrulheiro'
55 if (/database|banco|sql|migra/i.test(text)) return 'anao'
56 if (/\bpr\b|pull request|merge|release/i.test(text)) return 'arauto'
57 if (/integra|\bapi\b|\bmcp\b|webhook/i.test(text)) return 'alquimista'
58 return 'artifice'
59}
60
61/** Uma linha humana do que a chamada faz, para o diário e a faixa. */
62export const summaryOf = (tool: string, input: Record<string, unknown>) => {
63 for (const field of ['description', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'subject', 'command']) {
64 const value = input[field]
65 if (typeof value === 'string' && value.trim() !== '') {
66 const text = field === 'file_path' || field === 'notebook_path' ? baseName(value) : value.trim()
67 return text.length > 90 ? `${text.slice(0, 89)}…` : text
68 }
69 }
70 return tool
71}
72
73export const baseName = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
74
75/** +/− de um patch estruturado (Edit/Write). */
76export const patchStats = (patch: unknown) => {
77 let added = 0
78 let removed = 0
79 if (!Array.isArray(patch)) return undefined
80 for (const hunk of patch) {
81 const lines = (hunk as { lines?: unknown }).lines
82 if (!Array.isArray(lines)) continue
83 for (const line of lines) {
84 if (typeof line !== 'string') continue
85 if (line.startsWith('+')) added += 1
86 else if (line.startsWith('-')) removed += 1
87 }
88 }
89 return { added, removed }
90}
91
92/** Porcentagem legível, sem casa decimal. */
93export const pct = (value: number | undefined) => (value === undefined ? '—' : `${Math.round(value)}%`)
94
95/** Hora local de um ISO (HH:MM), ou vazio. */
96export const clockOf = (iso: string | undefined) => {
97 if (iso === undefined) return ''
98 const at = new Date(iso)
99 if (Number.isNaN(at.getTime())) return ''
100 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
101}
102
103/** Quanto falta para um ISO, em "2h14" ou "38min"; vazio quando já passou ou não há. */
104export const untilOf = (iso: string | undefined, now: number) => {
105 if (iso === undefined) return ''
106 const ms = new Date(iso).getTime() - now
107 if (Number.isNaN(ms) || ms <= 0) return ''
108 const min = Math.round(ms / 60000)
109 if (min < 60) return `${min}min`
110 const h = Math.floor(min / 60)
111 return h >= 48 ? `${Math.round(h / 24)}d` : `${h}h${String(min % 60).padStart(2, '0')}`
112}
113
114const KNOWN_TYPES: Record<string, string> = {
115 explore: 'Explorador',
116 plan: 'Arquiteto',
117 'claude-code-guide': 'Guia do Claude Code',
118 'statusline-setup': 'Status line',
119}
120const ACRONYM = /^(qa|ui|ux|api|pr|db|sql|ci|cd|mcp|hud)$/i
121
122/**
123 * O nome de um subagente para a tela: "acme-qa-reviewer" vira "QA reviewer",
124 * "claude-council:council-advisor" vira "Council advisor". Vazio quando o tipo
125 * não diz nada (general-purpose): aí quem chama mostra a classe.
126 */
127export const humanType = (type: string, known: Readonly<Record<string, string>> = KNOWN_TYPES) => {
128 const t = (type.split(':').pop() ?? type).trim()
129 if (t === '' || /^general(-purpose)?$/i.test(t)) return ''
130 const named = known[t.toLowerCase()]
131 if (named !== undefined) return named
132 let parts = t.split(/[-_\s]+/).filter(Boolean)
133 // Três palavras ou mais: a primeira costuma ser o prefixo da organização (acme-, org-).
134 if (parts.length >= 3) parts = parts.slice(1)
135 return parts.map((w, i) => (ACRONYM.test(w) ? w.toUpperCase() : i === 0 ? w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() : w.toLowerCase())).join(' ')
136}
137hooks/core/art.ts 393 lines1// Arte da HUD: os caranguejos Clawd das 9 classes, os ornamentos da pele Épico,
2// os ícones de arquivo e os do "Sobre". Tudo é texto de SVG, sem script: a
3// animação vem de CSS (<style>) e de SMIL, que o `Svg` interativo do desktop toca.
4// Portado de prototype/hud-prototype-final.html.
5
6import type { Beat, Cls, Palette, Rarity } from '../../types'
7
8export const CLASSES: readonly Cls[] = [
9 'druida',
10 'mago',
11 'ilusionista',
12 'artifice',
13 'paladino',
14 'patrulheiro',
15 'anao',
16 'arauto',
17 'alquimista',
18]
19
20const COLORS: Record<Palette, Record<Cls, string>> = {
21 escuro: {
22 druida: '#f0a272',
23 mago: '#6cc3ec',
24 ilusionista: '#b49cf0',
25 artifice: '#e9b75c',
26 paladino: '#f08fb8',
27 patrulheiro: '#5fc7b5',
28 anao: '#d8a65a',
29 arauto: '#ee7a8e',
30 alquimista: '#a8d07a',
31 },
32 claro: {
33 druida: '#a84e1a',
34 mago: '#136b98',
35 ilusionista: '#6845bd',
36 artifice: '#855a06',
37 paladino: '#a83b6c',
38 patrulheiro: '#147a69',
39 anao: '#7a5714',
40 arauto: '#b0324c',
41 alquimista: '#427a17',
42 },
43}
44
45/** Tons da pele e da raridade, nas duas paletas do protótipo. */
46// `card` é translúcido (hex de 8 dígitos): destaca a linha sobre qualquer fundo do app.
47export const TONE: Record<Palette, Record<'text' | 'muted' | 'gild' | 'gildDim' | 'ok' | 'bad' | 'warn' | 'pane' | 'card', string>> = {
48 escuro: { text: '#ece9df', muted: '#b3b0a6', gild: '#d9b26a', gildDim: '#8a7346', ok: '#8fc46a', bad: '#ee7a8e', warn: '#e6b450', pane: '#2b2a27', card: '#d9b26a17' },
49 claro: { text: '#1f1e1d', muted: '#57554f', gild: '#7a5310', gildDim: '#c9b48a', ok: '#3d7a1a', bad: '#b23450', warn: '#8a5e00', pane: '#f3f1ea', card: '#7a531014' },
50}
51
52const RARITY: Record<Palette, Record<Rarity, string>> = {
53 escuro: { lendario: '#f0a272', raro: '#6cb6ec', incomum: '#8fc46a', comum: '#d8d5cc', pobre: '#9a988f' },
54 claro: { lendario: '#a84e1a', raro: '#1b6aa0', incomum: '#3d7a1a', comum: '#4a4842', pobre: '#66645d' },
55}
56
57export const classColor = (cls: Cls, palette: Palette) => COLORS[palette][cls]
58export const rarityColor = (rarity: Rarity, palette: Palette) => RARITY[palette][rarity]
59
60const KEYFRAMES = [
61 '.eye{animation:blink 4.4s infinite}@keyframes blink{0%,94%,100%{opacity:1}96%{opacity:0}}',
62 '.sweep{transform-box:view-box;transform-origin:82px 56px;animation:sweep 3.2s ease-in-out infinite}@keyframes sweep{50%{transform:rotate(-7deg)}}',
63 '.mag{animation:mag 2.6s ease-in-out infinite}@keyframes mag{50%{transform:translateY(9px)}}',
64 '.gearr{transform-box:fill-box;transform-origin:center;animation:gr 4s linear infinite}@keyframes gr{to{transform:rotate(360deg)}}',
65 '.pick{transform-box:view-box;transform-origin:96px 66px;animation:pick 1.6s ease-in-out infinite}@keyframes pick{0%,100%{transform:rotate(-28deg)}45%{transform:rotate(22deg)}}',
66 '.stamp{animation:stamp 2.6s ease-in-out infinite}@keyframes stamp{0%,30%,80%,100%{transform:translateY(-8px)}45%,60%{transform:none}}',
67 '.page{transform-box:fill-box;transform-origin:left center;animation:page 2.2s ease-in-out infinite}@keyframes page{50%{transform:scaleX(-1)}}',
68 '.brush{transform-box:view-box;transform-origin:100px 62px;animation:brush 1.6s ease-in-out infinite}@keyframes brush{50%{transform:rotate(-14deg)}}',
69 '.bub{animation:bub 2.2s ease-in infinite;opacity:0}@keyframes bub{0%{opacity:0;transform:none}25%{opacity:1}100%{opacity:0;transform:translateY(-14px)}}',
70 '.glow{animation:glow 2.6s ease-in-out infinite}@keyframes glow{50%{opacity:.35}}',
71 '.bobs{animation:bob 2.2s ease-in-out infinite}@keyframes bob{50%{transform:translateY(-2px)}}',
72 '.lid{transform-box:fill-box;transform-origin:center bottom;animation:lid 1.8s ease-in-out infinite}@keyframes lid{0%,60%,100%{transform:none}75%{transform:rotate(-10deg) translateY(-3px)}}',
73 '.rune{animation:rune 3s ease-in-out infinite}@keyframes rune{50%{opacity:.25}}',
74 '@media (prefers-reduced-motion:reduce){*{animation:none!important}}',
75].join('')
76
77const BODY = [
78 '..OOOOOOOOOO..',
79 '..OOEOOOOEOO..',
80 'OOOOOOOOOOOOOO',
81 '..OOOOOOOOOO..',
82 '..OOOOOOOOOO..',
83 '..O.O....O.O..',
84]
85
86const body = () => {
87 let out = ''
88 BODY.forEach((row, y) =>
89 [...row].forEach((ch, x) => {
90 if (ch === '.') return
91 const X = 30 + x * 5
92 const Y = 50 + y * 5
93 out += `<rect x="${X}" y="${Y}" width="5.2" height="5.2" rx=".8" fill="#d97757"/>`
94 if (ch === 'E') out += `<rect class="eye" x="${X}" y="${Y}" width="5" height="5" rx="1.1" fill="#1a1a1a"/>`
95 }),
96 )
97 return out + '<rect x="41" y="51" width="22" height="2.2" rx="1.1" fill="#fff" opacity=".22"/>'
98}
99
100const hat = (cls: Cls, palette: Palette) => {
101 const c = classColor(cls, palette)
102 switch (cls) {
103 case 'mago':
104 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>`
105 case 'ilusionista':
106 return `<ellipse cx="65" cy="49" rx="24" ry="6" fill="${c}"/><rect x="63" y="40" width="4" height="5" fill="${c}"/>`
107 case 'artifice': {
108 const lens = classColor('patrulheiro', palette)
109 return `<rect x="40" y="46" width="50" height="3" fill="#8a6a45"/><rect x="47" y="41" width="13" height="9" rx="2" fill="${lens}" stroke="#3b3b45" stroke-width="2"/><rect x="70" y="41" width="13" height="9" rx="2" fill="${lens}" stroke="#3b3b45" stroke-width="2"/>`
110 }
111 case 'paladino':
112 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"/>'
113 case 'patrulheiro':
114 return `<path d="M38 52 L65 30 L92 52 Z" fill="${c}"/><path d="M50 52 L65 40 L80 52 Z" fill="#fff" opacity=".25"/>`
115 case 'anao':
116 return `<path d="M42 52 Q65 30 88 52 Z" fill="${c}"/><rect x="40" y="50" width="50" height="4" fill="#8a6a45"/><circle cx="65" cy="42" r="4" fill="#ffe08a"/>`
117 case 'arauto':
118 return `<polygon points="52,50 58,40 65,47 72,40 78,50" fill="${c}"/>`
119 case 'alquimista':
120 return `<rect x="40" y="46" width="50" height="3" fill="#3b3b45"/><circle cx="54" cy="46" r="5" fill="${c}" stroke="#3b3b45" stroke-width="2"/><circle cx="76" cy="46" r="5" fill="${c}" stroke="#3b3b45" stroke-width="2"/>`
121 case 'druida':
122 return `<text class="glow" x="31" y="32" font-size="11" fill="#f2c14e">✦</text><text class="glow" style="animation-delay:1.3s" x="92" y="28" font-size="9" fill="${classColor('ilusionista', palette)}">✧</text>`
123 }
124}
125
126const prop = (cls: Cls) => {
127 switch (cls) {
128 case 'patrulheiro':
129 return '<g class="sweep"><rect x="80" y="51" width="24" height="6" rx="1" fill="#e0af68" transform="rotate(-8 80 54)"/><rect x="100" y="47" width="6" height="9" rx="1" fill="#8a6a45" transform="rotate(-8 103 52)"/></g>'
130 case 'paladino':
131 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>'
132 case 'artifice':
133 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]
134 .map(a => `<rect x="14.5" y="35" width="3" height="18" fill="#e9b75c" transform="rotate(${a} 16 44)"/>`)
135 .join('')}</g>`
136 case 'anao':
137 return '<g class="pick"><line x1="96" y1="66" x2="110" y2="50" stroke="#8a6a45" stroke-width="3"/><path d="M102 44 Q112 46 118 56" stroke="#9aa0b5" stroke-width="3" fill="none"/></g>'
138 case 'arauto':
139 return '<g class="stamp"><rect x="104" y="48" width="10" height="11" fill="#8a6a45"/><rect x="102" y="59" width="14" height="5" fill="#d0473b"/></g>'
140 case 'mago':
141 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"/>'
142 case 'ilusionista':
143 return '<g class="brush"><line x1="100" y1="62" x2="110" y2="44" stroke="#e0af68" stroke-width="2.5"/><circle cx="110" cy="44" r="2.5" fill="#ff9ecd"/></g>'
144 case 'alquimista':
145 return `${[0, 0.7, 1.4]
146 .map(d => `<circle class="bub" style="animation-delay:${d}s" cx="${104 + d * 6}" cy="58" r="2.4" fill="#c3e88d"/>`)
147 .join('')}<path d="M98 66 L112 66 L115 80 L95 80 Z" fill="#c3e88d" fill-opacity=".6" stroke="#8a8a80"/>`
148 case 'druida':
149 return ''
150 }
151}
152
153export const esc = (text: string) =>
154 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
155
156/** O que um `Svg` do desktop recebe. */
157export type Art = { source: string; width: number; height: number; alt: string }
158
159/** O Clawd de uma classe; com `withProp`, o acessório animado entra no quadro. */
160export const crab = (cls: Cls, width: number, palette: Palette, tip: string, withProp = false): Art => {
161 const viewBox = withProp ? '0 10 130 76' : '28 12 76 74'
162 const height = Math.round(withProp ? (width * 76) / 130 : (width * 74) / 76)
163 const source =
164 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width}" height="${height}">` +
165 `<title>${esc(tip)}</title><style>${KEYFRAMES}</style><g class="bobs">${body()}${hat(cls, palette)}</g>` +
166 (withProp ? prop(cls) : '') +
167 '</svg>'
168 return { source, width, height, alt: tip }
169}
170
171/**
172 * A linha do diário: o Clawd à esquerda e o trilho na cor da classe, com a
173 * bolinha centralizada. Linhas seguidas encostam os trilhos.
174 */
175export const diaryMark = (cls: Cls, palette: Palette, tip: string, state: 'run' | 'ok' | 'err'): Art => {
176 const c = classColor(cls, palette)
177 const dot = state === 'err' ? TONE[palette].bad : c
178 const pulse =
179 state === 'run' ? '<animate attributeName="r" values="4;6;4" dur="1.4s" repeatCount="indefinite"/>' : ''
180 const source =
181 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 44" width="56" height="44">' +
182 `<title>${esc(tip)}</title><style>${KEYFRAMES}</style>` +
183 `<svg x="0" y="2" width="40" height="40" viewBox="28 12 76 74"><g class="bobs">${body()}${hat(cls, palette)}</g></svg>` +
184 `<line x1="49" y1="0" x2="49" y2="44" stroke="${c}" stroke-opacity=".55" stroke-width="2"/>` +
185 (state === 'run'
186 ? `<circle cx="49" cy="22" r="4.5" fill="${dot}">${pulse}</circle></svg>`
187 : `<circle cx="49" cy="22" r="4.5" fill="${TONE[palette].pane}" stroke="${dot}" stroke-width="2"/></svg>`)
188 return { source, width: 56, height: 44, alt: tip }
189}
190
191/**
192 * Título de seção. Pele Épico: centralizado, com ornamentos ◆ e o "!" ou "?"
193 * do quest log. Pele Sóbrio: alinhado à esquerda, sem ornamento.
194 */
195export const sectionTitle = (
196 title: string,
197 palette: Palette,
198 skin: 'epico' | 'sobrio',
199 width: number,
200 mark?: '!' | '?',
201): Art => {
202 const t = TONE[palette]
203 const w = Math.max(160, Math.round(width))
204 if (skin === 'sobrio') {
205 return {
206 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 20" width="${w}" height="20"><text x="0" y="14" font-family="system-ui,sans-serif" font-size="11" font-weight="700" letter-spacing=".12em" fill="${t.muted}">${esc(title.toUpperCase())}</text></svg>`,
207 width: w,
208 height: 20,
209 alt: title,
210 }
211 }
212 const label = (mark === undefined ? '' : `${mark} `) + title.toUpperCase()
213 const half = w / 2
214 const textW = label.length * 9.2
215 const left = Math.max(8, half - textW / 2 - 12)
216 const right = Math.min(w - 8, half + textW / 2 + 12)
217 const source =
218 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 22" width="${w}" height="22">` +
219 `<line x1="4" y1="11" x2="${left - 8}" y2="11" stroke="${t.gildDim}"/>` +
220 `<path d="M${left - 8} 11 l4 -4 l4 4 l-4 4 Z" fill="${t.gild}"/>` +
221 `<text x="${half}" y="15" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10.5" font-weight="700" letter-spacing=".28em" fill="${t.gild}">${esc(label)}</text>` +
222 `<path d="M${right} 11 l4 -4 l4 4 l-4 4 Z" fill="${t.gild}"/>` +
223 `<line x1="${right + 8}" y1="11" x2="${w - 4}" y2="11" stroke="${t.gildDim}"/></svg>`
224 return { source, width: w, height: 22, alt: label }
225}
226
227/** Fileira de quests concluídas: quadrados como os da demo, cada um com o nome no tooltip. */
228export const doneQuestsArt = (titles: readonly string[], palette: Palette): Art => {
229 const t = TONE[palette]
230 const shown = titles.slice(-8)
231 const w = Math.max(26, shown.length * 26)
232 const source =
233 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 24" width="${w}" height="24">` +
234 shown
235 .map(
236 (title, i) =>
237 `<g transform="translate(${i * 26 + 1} 1)"><title>✓ ${esc(title)}</title><rect x=".5" y=".5" width="21" height="21" rx="4" fill="${t.muted}" fill-opacity=".1" stroke="${t.muted}" stroke-opacity=".55"/></g>`,
238 )
239 .join('') +
240 '</svg>'
241 return { source, width: w, height: 24, alt: shown.map(s => `✓ ${s}`).join(', ') }
242}
243
244/** O quadrado de uma quest aberta: dourado na atual, apagado nas próximas. */
245export const questBox = (current: boolean, palette: Palette, tip: string): Art => {
246 const t = TONE[palette]
247 const stroke = current ? t.gild : t.muted
248 const source =
249 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><title>${esc(tip)}</title>` +
250 `<rect x="1.5" y="1.5" width="21" height="21" rx="4" fill="${t.muted}" fill-opacity=".08" stroke="${stroke}" stroke-opacity="${current ? 1 : 0.55}" stroke-width="${current ? 1.6 : 1}"/></svg>`
251 return { source, width: 24, height: 24, alt: tip }
252}
253
254const FILE_ICON = {
255 env: (c: string) =>
256 `<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"/>`,
257 pipeline: (c: string) =>
258 `<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"/>`,
259 file: (c: string) =>
260 `<path d="M2 1 H10 L14 5 V19 H2 Z" fill="none" stroke="${c}" stroke-width="1.6"/><path d="M10 1 V5 H14" fill="none" stroke="${c}" stroke-width="1.4"/>`,
261}
262
263export const fileKind = (name: string): keyof typeof FILE_ICON =>
264 /pipelines?.*\.ya?ml$/i.test(name) ? 'pipeline' : /(^|[\\/])\.env/i.test(name) ? 'env' : 'file'
265
266/** Um item do inventário: ícone na cor da raridade, com o nome e a raridade no tooltip. */
267export const itemIcon = (name: string, rarity: Rarity, palette: Palette, tip: string): Art => {
268 const c = rarityColor(rarity, palette)
269 const source =
270 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -1 20 22" width="18" height="20"><title>${esc(tip)}</title>` +
271 `<rect x="-1" y="0" width="18" height="20" rx="3" fill="${c}" fill-opacity=".14"/>${FILE_ICON[fileKind(name)](c)}</svg>`
272 return { source, width: 18, height: 20, alt: tip }
273}
274
275/** Ícones do "Sobre". Paths simples, desenhados aqui, sem copiar logotipo. */
276export const linkIcon = (kind: 'linkedin' | 'github', palette: Palette, tip: string): Art => {
277 const c = TONE[palette].text
278 const glyph =
279 kind === 'linkedin'
280 ? `<rect x="1" y="1" width="18" height="18" rx="3.5" fill="none" stroke="${c}" stroke-width="1.6"/><rect x="5" y="8.5" width="2.4" height="7" fill="${c}"/><circle cx="6.2" cy="5.8" r="1.5" fill="${c}"/><path d="M10 15.5 V8.5 H12.3 V9.6 Q13.2 8.3 14.8 8.4 Q16.6 8.6 16.6 11 V15.5 H14.3 V11.6 Q14.3 10.3 13.3 10.3 Q12.3 10.3 12.3 11.6 V15.5 Z" fill="${c}"/>`
281 : `<circle cx="10" cy="10" r="8.6" fill="none" stroke="${c}" stroke-width="1.6"/><path d="M7.4 17.6 V14.6 Q6.9 13.6 7.8 12.9 Q5.2 12.4 5.2 9.6 Q5.2 8.4 6 7.6 Q5.7 6.6 6.1 5.6 Q7.2 5.6 8.1 6.4 Q10 5.8 11.9 6.4 Q12.8 5.6 13.9 5.6 Q14.3 6.6 14 7.6 Q14.8 8.4 14.8 9.6 Q14.8 12.4 12.2 12.9 Q13.1 13.6 12.6 14.6 V17.6" fill="none" stroke="${c}" stroke-width="1.4" stroke-linejoin="round"/>`
282 return {
283 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><title>${esc(tip)}</title>${glyph}</svg>`,
284 width: 20,
285 height: 20,
286 alt: tip,
287 }
288}
289
290/** Barra fina, como a da demo: trilho escuro e o preenchido na cor dada. */
291export const meter = (fraction: number | undefined, color: string, palette: Palette, tip: string, width = 64, extra = ''): Art => {
292 const t = TONE[palette]
293 const w = Math.max(24, Math.round(width))
294 const f = fraction === undefined ? 0 : Math.max(0, Math.min(1, fraction))
295 const source =
296 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 8" width="${w}" height="8"><title>${esc(tip)}</title>` +
297 `<rect x="0" y="1" width="${w}" height="6" rx="3" fill="${t.muted}" fill-opacity=".22"/>` +
298 `<rect x="0" y="1" width="${Math.round(w * f)}" height="6" rx="3" fill="${color}">${extra}</rect></svg>`
299 return { source, width: w, height: 8, alt: tip }
300}
301
302/** Barra de recurso do rodapé: preenchida pelo que resta, com o rótulo no tooltip. */
303export const resourceBar = (
304 kind: 'mochila' | 'mana' | 'vigor',
305 fraction: number | undefined,
306 palette: Palette,
307 tip: string,
308): Art => {
309 const color = kind === 'mochila' ? '#b49cf0' : kind === 'mana' ? '#6cb6ec' : '#8fc46a'
310 // A mochila "respira" quando está cheia (o que se usa, não o que resta).
311 const breathe =
312 kind === 'mochila' && (fraction ?? 0) >= 0.8
313 ? '<animate attributeName="opacity" values="1;.55;1" dur="2.4s" repeatCount="indefinite"/>'
314 : ''
315 return meter(fraction, color, palette, tip, 40, breathe)
316}
317
318/** O Guardião do Cofre: um baú mímico em pixel art, com a tampa que range e os olhos que piscam. */
319export const mimic = (palette: Palette, tip: string, width = 96): Art => {
320 const wood = palette === 'escuro' ? '#8a5a2b' : '#7a4a1f'
321 const band = '#d9b26a'
322 const height = Math.round((width * 70) / 96)
323 const teeth = [0, 1, 2, 3, 4, 5].map(i => `<path d="M${22 + i * 9} 38 l4.5 7 l4.5 -7 Z" fill="#f4efe2"/>`).join('')
324 const source =
325 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 70" width="${width}" height="${height}"><title>${esc(tip)}</title><style>${KEYFRAMES}</style>` +
326 `<rect x="14" y="38" width="68" height="28" rx="3" fill="${wood}"/><rect x="14" y="48" width="68" height="4" fill="${band}"/>` +
327 `<rect x="44" y="44" width="8" height="12" rx="1.5" fill="${band}"/><rect x="46.5" y="48" width="3" height="4" fill="#3b2a14"/>` +
328 `<rect x="18" y="38" width="60" height="8" fill="#5a1d1d"/>${teeth}` +
329 `<g class="lid"><path d="M12 38 Q48 6 84 38 Z" fill="${wood}"/><path d="M12 38 Q48 6 84 38" fill="none" stroke="${band}" stroke-width="3"/>` +
330 `<rect class="eye" x="34" y="24" width="7" height="6" rx="1.5" fill="#f2c14e"/><rect class="eye" x="55" y="24" width="7" height="6" rx="1.5" fill="#f2c14e"/>` +
331 `<rect x="36.5" y="25.5" width="2.5" height="3" fill="#1a1a1a"/><rect x="57.5" y="25.5" width="2.5" height="3" fill="#1a1a1a"/></g></svg>`
332 return { source, width, height, alt: tip }
333}
334
335/** O livro do grimório, aberto, com runas que pulsam. */
336export const book = (palette: Palette, tip: string, width = 84): Art => {
337 const t = TONE[palette]
338 const page = palette === 'escuro' ? '#e9e2cf' : '#fffaf0'
339 const height = Math.round((width * 56) / 84)
340 const lines = [0, 1, 2, 3]
341 .map(i => `<line x1="${12}" y1="${18 + i * 7}" x2="${36 - (i % 2) * 6}" y2="${18 + i * 7}" stroke="${t.muted}" stroke-width="1.6"/>`)
342 .join('')
343 const source =
344 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 56" width="${width}" height="${height}"><title>${esc(tip)}</title><style>${KEYFRAMES}</style>` +
345 `<path d="M4 10 Q22 4 42 10 L42 50 Q22 44 4 50 Z" fill="${page}" stroke="${t.gild}" stroke-width="1.6"/>` +
346 `<path d="M80 10 Q62 4 42 10 L42 50 Q62 44 80 50 Z" fill="${page}" stroke="${t.gild}" stroke-width="1.6"/>${lines}` +
347 `<text class="rune" x="50" y="26" font-size="12" fill="${classColor('mago', palette)}">ᚱ</text>` +
348 `<text class="rune" style="animation-delay:1s" x="62" y="38" font-size="12" fill="${classColor('ilusionista', palette)}">ᛟ</text></svg>`
349 return { source, width, height, alt: tip }
350}
351
352/** A linha do tempo do relatório: uma bolinha por ação, na cor da classe, com o tooltip. */
353export const timeline = (beats: readonly Beat[], palette: Palette, label: (b: Beat) => string, width = 300): Art => {
354 const t = TONE[palette]
355 const w = Math.max(120, Math.round(width))
356 const n = Math.max(1, beats.length)
357 const step = (w - 20) / Math.max(1, n - 1)
358 const dots = beats
359 .map((b, i) => {
360 const x = n === 1 ? w / 2 : 10 + i * step
361 const fill = b.status === 'err' ? t.bad : classColor(b.cls, palette)
362 return `<g><title>${esc(label(b))}</title><circle cx="${x.toFixed(1)}" cy="10" r="${b.status === 'err' ? 5 : 4}" fill="${fill}"/></g>`
363 })
364 .join('')
365 const source =
366 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 20" width="${w}" height="20">` +
367 `<line x1="6" y1="10" x2="${w - 6}" y2="10" stroke="${t.gildDim}" stroke-width="1.5"/>${dots}</svg>`
368 return { source, width: w, height: 20, alt: beats.map(label).join(' · ') }
369}
370
371/** A linha acima do diálogo de proteção: ícone, nome e +/− de cada arquivo, com tooltip. */
372export const guardLine = (files: readonly { name: string; added: number; removed: number }[], palette: Palette, tip: (name: string) => string): Art => {
373 const t = TONE[palette]
374 const legendary = rarityColor('lendario', palette)
375 let x = 0
376 let out = ''
377 for (const file of files) {
378 const delta = `<tspan fill="${t.ok}">+${file.added}</tspan>${file.removed > 0 ? ` <tspan fill="${t.bad}">−${file.removed}</tspan>` : ''}`
379 const chars = file.name.length + 2 + String(file.added).length + (file.removed > 0 ? String(file.removed).length + 3 : 0)
380 out +=
381 `<g transform="translate(${x} 3)"><title>${esc(tip(file.name))}</title><g>${FILE_ICON[fileKind(file.name)](legendary)}</g>` +
382 `<text x="22" y="14" font-family="system-ui,sans-serif" font-size="12.5" fill="${t.text}">${esc(file.name)} ${delta}</text></g>`
383 x += 26 + chars * 7.2 + 14
384 }
385 const width = Math.max(1, Math.ceil(x))
386 return {
387 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} 24" width="${width}" height="24">${out}</svg>`,
388 width,
389 height: 24,
390 alt: files.map(f => `${f.name} +${f.added}${f.removed > 0 ? ` −${f.removed}` : ''}`).join(' · '),
391 }
392}
393hooks/core/i18n.ts 644 lines1// Textos da HUD em português do Brasil e em inglês. A língua sai da opção
2// `language` do mod (auto, pt-BR ou en); em `auto`, segue a do Claude.
3
4import type { Cls, Destination, GuardMode, InscriptionKind, Lang, ModuleId, Profile, Rarity, Section } from '../../types'
5
6const PT = {
7 className: {
8 druida: 'Clawd',
9 mago: 'Mago',
10 ilusionista: 'Ilusionista',
11 artifice: 'Artífice',
12 paladino: 'Paladino',
13 patrulheiro: 'Patrulheiro',
14 anao: 'Anão minerador',
15 arauto: 'Arauto',
16 alquimista: 'Alquimista',
17 } satisfies Record<Cls, string>,
18 section: {
19 objetivo: 'Objetivo',
20 quests: 'Quests do capítulo',
21 grupo: 'Grupo',
22 inventario: 'Inventário',
23 relatorio: 'Relatório da missão',
24 rodape: 'Rodapé',
25 } satisfies Record<Section, string>,
26 module: {
27 diario: ['Diário de bordo', 'Mascote, trilho e grupos nas linhas de ferramenta'],
28 faixa: ['Faixa acima do prompt', 'O pet, a ação em curso e o objetivo em miniatura'],
29 painel: ['Painel', 'Objetivo, quests, grupo, inventário e rodapé'],
30 campanha: ['Campanha', 'Objetivo, capítulos e a comemoração no fim da missão'],
31 guarda: ['Guarda dos protegidos', 'Pergunta antes de editar .env, pipelines e afins (e antes de comandos que os escrevem)'],
32 mochila: ['Mochila', 'Cartão de compactação quando o contexto enche'],
33 grimorio: ['Grimório', 'Aprendizados para CLAUDE.md, AGENTS.md, vault e ai-memory'],
34 sideQuest: ['Side quest', 'Sessão paralela com o contexto da campanha'],
35 comemoracao: ['Comemoração', 'O Clawd festeja no fim de uma missão'],
36 projetos: ['Projetos', 'Projetos fixados, as pastas de cada um e o acesso do Claude a elas'],
37 } satisfies Record<ModuleId, [string, string]>,
38 tokens: 'pode gastar tokens (só com o seu clique)',
39 paneNotPlaced: (why: string) => `O painel não abriu (${why}). Alargue a janela do Claude Code.`,
40 profile: { completo: 'Completo', foco: 'Foco', minimo: 'Mínimo', personalizado: 'Personalizado' } satisfies Record<Profile, string>,
41 rarity: {
42 lendario: 'Lendário: protegidos (.env, pipelines, segredos)',
43 raro: 'Raro: críticos (API, banco, auth, build)',
44 incomum: 'Incomum: código e telas',
45 comum: 'Comum: testes, docs e config leve',
46 pobre: 'Pobre: excluídos',
47 } satisfies Record<Rarity, string>,
48 noObjective: 'O objetivo aparece com a primeira mensagem da sessão.',
49 setupMemoryTitle: 'Memória entre sessões',
50 setupTagline: 'Quatro passos rápidos. Dá para mudar tudo depois no ⚙.',
51 knownTypes: { explore: 'Explorador', plan: 'Arquiteto', 'claude-code-guide': 'Guia do Claude Code', 'statusline-setup': 'Status line' } as Record<string, string>,
52 ago: (s: number) => (s < 90 ? 'agora' : s < 3600 ? `há ${Math.round(s / 60)} min` : s < 86400 ? `há ${Math.round(s / 3600)} h` : `há ${Math.round(s / 86400)} d`),
53 catGeneral: 'Geral',
54 catPanel: 'Painel',
55 catMemory: 'Memória',
56 rowChat: 'No chat',
57 rowPanel: 'No painel',
58 rowTools: 'Ferramentas',
59 rowGuard: 'Proteção',
60 langAutoShort: 'Auto',
61 profileTitleShort: 'Perfil',
62 showShort: 'Mostrar',
63 readsRow: 'Leituras',
64 guardRow: 'Protegidos',
65 grimoireRow: 'Grimório',
66 haikuShort: 'Resumir com Haiku',
67 aboutTagline: (v: string) => `v${v} · uma HUD de RPG aconchegante para o Claude Code`,
68 aboutBy: 'Quem fez',
69 authorRole: 'desenvolvedor · Angular, .NET e Azure DevOps',
70 aboutFeatures: [
71 ['Objetivo sempre à vista', 'O 1º pedido vira a campanha; quests e capítulos mostram o caminho.'],
72 ['Grupo e expedições', 'Cada subagente vira um herói; os de fundo aparecem na faixa.'],
73 ['Inventário por impacto', 'Arquivos mexidos com raridade: protegido é lendário.'],
74 ['Guarda dos protegidos', '.env e pipelines pedem aprovação antes de mudar.'],
75 ['Grimório', 'Decisões e conquistas guardadas só com o seu clique.'],
76 ] as [string, string][],
77 campaignLabel: 'Campanha',
78 recalibrate: '✦ Recalibrar objetivo',
79 recalibrateDone: (goal: string) => `✦ Objetivo: ${goal}`,
80 recalibrateFail: 'O Haiku não respondeu; o objetivo ficou como estava.',
81 recalibratePrompt: (asks: string[]) =>
82 `Estes são os pedidos que a pessoa fez nesta sessão do Claude Code, do primeiro ao mais recente:
83${asks.map((a, n) => `${n + 1}. ${a}`).join('\n')}
84
85Escreva o objetivo ATUAL da sessão em até 8 palavras, em português do Brasil, começando por um verbo no infinitivo. Responda só com o objetivo, sem aspas nem ponto final.`,
86 chaptersTitle: 'Capítulos',
87 chaptersDone: (n: number) => (n === 1 ? '1 capítulo fechado' : `${n} capítulos fechados`),
88 questsHint: 'O Claude ainda não listou passos: os capítulos contam a história.',
89 waitingYou: 'esperando você',
90 atCamp: 'no acampamento · esperando você',
91 resting: 'descansando',
92 onExpedition: 'em expedição',
93 partyHint: 'Os subagentes que o Claude chamar entram no grupo.',
94 recentCommits: 'Últimos commits',
95 bandIdle: 'Pronto · aguardando você',
96 moreActive: (n: number) => `+${n} em ação`,
97 expedition: (n: number, first: string) => (n === 1 ? `${first} em expedição` : `${n} agentes em expedição`),
98 expeditionTip: (lines: string[]) => ['Rodando em segundo plano:', ...lines].join('\n'),
99 failedShort: 'falhou',
100 deleted: 'excluído',
101 compactShort: '🎒 Compactar',
102 bandStep: (n: number, total: number) => `Passo ${n} de ${total}`,
103 stepsWord: (_done: number, total: number): string => (total === 1 ? 'passo' : 'passos'),
104 chapterShort: (n: number) => `cap. ${n}`,
105 stepTip: (n: number, name: string, state: 'done' | 'now' | 'next') =>
106 `Passo ${n} · ${name} — ${state === 'done' ? 'concluído' : state === 'now' ? 'em andamento' : 'a fazer'}`,
107 rarityByImpact: 'Raridade pelo impacto',
108 rarities: 'Raridades',
109 achieved: '✓ Objetivo alcançado',
110 markAchieved: 'Marcar como alcançado',
111 noQuests: 'Sem quests ainda. Elas vêm da lista de tarefas do Claude.',
112 questsMist: (n: number) => `+${n} na névoa`,
113 chapterLine: (n: number, origin: string) => (origin === '' ? `cap. ${n}` : `cap. ${n} · começou como: ${origin}`),
114 questsDone: (n: number) => (n === 1 ? '1 concluída' : `${n} concluídas`),
115 inProgress: 'em andamento',
116 inAction: 'em ação',
117 mainSession: (cls: string) => `sessão principal · forma de ${cls.toLowerCase()}`,
118 missionDone: 'missão cumprida',
119 doneHint: 'concluídos · passe o mouse',
120 noParty: 'Ninguém no grupo. Subagentes aparecem aqui.',
121 partyDone: 'Concluídos',
122 notRepo: 'Fora de um repositório git.',
123 clean: 'Nada mexido ainda.',
124 uncommitted: (n: number) => `● ${n} sem commit`,
125 ahead: (n: number) => `↑${n}`,
126 behind: (n: number) => `↓${n}`,
127 actions: (n: number) => (n === 1 ? '1 ação' : `${n} ações`),
128 isNew: 'novo',
129 failed: (n: number) => (n === 1 ? '1 falhou' : `${n} falharam`),
130 running: 'em execução',
131 expandGroup: 'Separar',
132 splitHint: 'clique em Separar para ver uma a uma',
133 footer: { mochila: 'Mochila', mana: 'Mana', vigor: 'Vigor' },
134 mochilaTip: (p: string) => `Mochila (contexto): ${p} usado. Clique para preparar a compactação.`,
135 manaTip: (p: string, at: string) => `Mana (janela de 5h): ${p} restante${at === '' ? '' : `, renova ${at}`}`,
136 vigorTip: (p: string, at: string) => `Vigor (semanal): ${p} restante${at === '' ? '' : `, renova ${at}`}`,
137 resetAt: (clock: string, until: string) => (until === '' ? clock : `às ${clock} (em ${until})`),
138 off: (name: string) => `${name} está desligado no ⚙.`,
139 grimoire: '📖 Grimório',
140 sideQuest: '⇄ Side quest',
141 config: '⚙',
142 about: 'ℹ',
143 back: '‹ Voltar',
144 configTitle: 'Configurações',
145 aboutTitle: 'Sobre',
146 language: 'Língua',
147 langAuto: 'Automática (a do Claude)',
148 skin: 'Pele',
149 skinEpico: 'Épico',
150 skinSobrio: 'Sóbrio',
151 profileTitle: 'Perfil do painel',
152 show: 'Mostrar no painel',
153 modules: 'Módulos',
154 diaryMode: 'Diário',
155 grouped: 'Agrupado',
156 separate: 'Separado',
157 diaryHint: 'No modo Agrupado, Separar › abre só aquele grupo; o resto continua agrupado.',
158 guardMode: 'Proteção',
159 guardModes: {
160 nativo: 'Só o diálogo nativo',
161 dialogo: 'Diálogo do Claude',
162 chefe: 'Encontro com chefe',
163 } satisfies Record<GuardMode, string>,
164 guardHint: 'Nativo só avisa no diálogo de permissão (se ele aparecer). Diálogo e chefe sempre perguntam.',
165 haiku: 'Botão “Resumir com Haiku” no grimório',
166 haikuButton: '✦ Resumir com Haiku',
167 haikuDone: (n: number) => `${n} inscrição(ões) resumida(s) pelo Haiku.`,
168 join: '⊟ Juntar',
169 split: '⊞ Separar',
170 // Guarda
171 guardQuestion: (names: string) => `O Claude quer alterar ${names}, que são protegidos. O que fazer?`,
172 bossQuestion: (names: string) => `O Guardião do Cofre bloqueia ${names}. Como passar?`,
173 guardHeader: 'Proteção',
174 bossHeader: 'Cofre',
175 guardHints: {
176 dialogo: [
177 'O Claude grava a mudança como pediu.',
178 'Bloqueia agora e leva um rascunho ao prompt para você ajustar a mudança.',
179 'Bloqueia; a resposta final e o relatório registram a recusa.',
180 ],
181 chefe: [
182 'O mímico abre o baú: a mudança é gravada.',
183 'Bloqueia e leva um rascunho ao prompt; o Claude espera você enviar.',
184 'Bloqueia; o relatório registra a recusa.',
185 ],
186 },
187 bossTitle: 'Guardião do Cofre',
188 bossSubtitle: 'Arquivos protegidos à frente',
189 guardNotice: (names: string) => `🔒 ${names}: arquivo protegido (hero-journey)`,
190 denyRefuse: (name: string) => `${name} é protegido e a pessoa recusou a alteração. Não tente de novo; diga na resposta final que a mudança ficou de fora.`,
191 denyNegotiate: (name: string) => `${name} é protegido: a pessoa quer ajustar a mudança antes. Espere a próxima mensagem dela.`,
192 denyHeadless: (name: string) => `${name} é protegido e não há ninguém para aprovar. Peça a mudança à pessoa.`,
193 denyFailed: 'O guarda do hero-journey falhou, então a alteração foi bloqueada. Peça para a pessoa fazer a mudança.',
194 negotiateDraft: (name: string) => `Sobre a mudança em ${name}: `,
195 // Relatório
196 reportRunning: 'em andamento',
197 reportNone: 'O relatório aparece no fim da primeira missão.',
198 reportOf: (i: number, n: number) => `${i} de ${n}`,
199 chapter: (n: number) => `Capítulo ${n}`,
200 result: 'Resultado',
201 risk: 'Risco',
202 next: 'Próximo',
203 waits: 'Aguarda você',
204 refused: 'Recusado',
205 stats: (actions: number, files: number, added: number, removed: number, dur: string) =>
206 `${actions === 1 ? '1 ação' : `${actions} ações`} · ${files === 1 ? '1 arquivo' : `${files} arquivos`} · +${added} −${removed} · ${dur}`,
207 approve: '✓ Aprovar',
208 adjust: '✎ Ajustar',
209 approved: '✓ Aprovado',
210 adjustDraft: (title: string) => `Ajuste na missão "${title}": `,
211 // Mochila
212 compactTitle: 'Mochila cheia',
213 compactBody: (p: string) =>
214 `O contexto está em ${p}. Leve a instrução ao prompt e envie quando quiser, ou compacte agora com o Claude parado.`,
215 toPrompt: 'Levar ao prompt',
216 compactNow: 'Compactar agora',
217 compactBusy: 'O Claude está trabalhando: a compactação só roda com ele parado.',
218 compactDone: (before: string, after: string) => `Mochila arrumada: ${before} → ${after}.`,
219 notNow: 'Agora não',
220 filled: 'Está no prompt. Envie quando quiser.',
221 noComposer: 'Não há caixa de digitação aqui: o texto foi copiado.',
222 // Grimório
223 grimTitle: 'Grimório',
224 grimSubtitle: 'O Mago anotou o que vale guardar',
225 grimCount: (n: number) => (n === 1 ? 'O Mago anotou 1 inscrição para você selar' : `O Mago anotou ${n} inscrições para você selar`),
226 grimEmpty: 'Nenhuma inscrição pendente.',
227 grimKind: { decisao: 'Decisão', conquista: 'Conquista', preferencia: 'Preferência' } satisfies Record<InscriptionKind, string>,
228 grimWhy: {
229 decisao: 'Decisão técnica: vai para o vault. CLAUDE.md e AGENTS.md viram instrução permanente do Claude: marque só se quiser.',
230 conquista: 'Conquista com número: vai para as notas de carreira no vault.',
231 preferencia: 'Preferência de trabalho com IA: vai para o perfil do ai-memory, que vale em todos os projetos.',
232 } satisfies Record<InscriptionKind, string>,
233 dest: {
234 claudeMd: 'CLAUDE.md',
235 agentsMd: 'AGENTS.md',
236 vaultDecisao: 'Vault · decisões',
237 carreira: 'Carreira',
238 aiMemory: 'ai-memory',
239 } satisfies Record<Destination, string>,
240 grimNoSafeDest: 'Para guardar sem virar instrução do Claude, configure um vault ou o ai-memory em ⚙ → ✦ Primeiros passos.',
241 grimNoneMarked: 'Marque ao menos um destino para selar.',
242 destWaiting: (d: string) => `⏳ ${d}: indisponível agora, grava no próximo Selar`,
243 seal: 'Selar',
244 discard: 'Descartar',
245 sealed: (n: number) => (n === 1 ? '1 inscrição selada.' : `${n} inscrições seladas.`),
246 notSealed: (n: number, why: string) =>
247 `${n === 0 ? 'Um destino falhou.' : n === 1 ? '1 inscrição continua pendente: destino indisponível ou com falha.' : `${n} inscrições continuam pendentes: destino indisponível ou com falha.`}${why === '' ? '' : ` ${why}`}`,
248 privacyBlocked: 'Ficou fora do CLAUDE.md/AGENTS.md: o texto cita o vault ou um caminho pessoal.',
249 decisionsHeading: 'Decisões',
250 grimSealedList: 'Seladas nesta sessão',
251 // Side quest
252 sqTitle: 'Side quest',
253 sqBody: 'Abre uma sessão paralela para um desvio, com o resumo desta campanha. O pedido vai ao prompt; você completa a tarefa e envia.',
254 copy: 'Copiar resumo',
255 copied: 'Resumo copiado.',
256 // Projetos
257 projects: '🗂 Projetos',
258 projTitle: 'Projetos',
259 projNote: 'A barra lateral do app não é editável por mods, então os projetos ficam aqui. Cada projeto junta várias pastas; o Claude só alcança as que têm ✓.',
260 projCurrent: 'Este projeto',
261 projNone: 'Esta pasta ainda não é de nenhum projeto.',
262 projAddCwd: '+ Cadastrar esta pasta como projeto',
263 projPinned: 'Fixados',
264 projOthers: 'Outros',
265 projEmpty: 'Nenhum projeto ainda.',
266 projNew: 'Novo projeto',
267 projNewPlaceholder: 'Nome do projeto',
268 projFolderPlaceholder: 'Caminho de outra pasta (ex.: C:\\Projetos\\api)',
269 projAddFolder: 'Adicionar',
270 projCreate: 'Criar',
271 pin: '📌 Fixar',
272 unpin: '📌 Desafixar',
273 remove: '✕',
274 confirmRemove: '✕ Confirmar remover?',
275 openSession: '↗ Abrir sessão',
276 accessNow: 'Acesso nesta sessão',
277 accessAlways: 'Acesso sempre (nesta pasta)',
278 accessOk: 'o Claude alcança',
279 accessNo: 'sem acesso',
280 accessSaved: (n: number) => `${n === 1 ? '1 pasta liberada' : `${n} pastas liberadas`} em .claude/settings.local.json.`,
281 accessFailed: (why: string) => `Não deu para gravar o settings.local.json: ${why}`,
282 folderMissing: (path: string) => `A pasta não existe (ou é um arquivo): ${path}`,
283 folderNetwork: (path: string) => `Caminho de rede não suportado: ${path}`,
284 // Primeiros passos
285 setupTitle: 'Primeiros passos',
286 setupIntro: 'Duas perguntas rápidas. Dá para mudar tudo depois no ⚙ ou em /config. O ai-memory não pede pasta: basta o MCP dele estar conectado.',
287 setupOpen: '✦ Primeiros passos',
288 setupVaultQ: 'Você usa um vault (Obsidian ou parecido) para notas e carreira?',
289 setupYes: 'Sim, uso',
290 setupNo: 'Não uso',
291 setupPathVault: 'Caminho do vault (ex.: C:\\Notas)',
292 setupPathInbox: 'Pasta de entrada no vault (padrão: Inbox)',
293 inboxInvalid: (v: string) => `“${v}” precisa ser uma subpasta do vault, sem caminho absoluto nem “..”.`,
294 setupMemoryOk: '✓ ai-memory conectado',
295 setupMemoryTip:
296 'Recomendado: o ai-memory, de Fabio Akita. Ele dá memória de longo prazo ao Claude: lembra o que aconteceu nas sessões e as suas preferências, em todos os projetos. Com o MCP dele conectado, o grimório grava nele.',
297 setupMemoryLink: 'ai-memory no GitHub',
298 setupObsidianTip: 'Sem vault? O Obsidian é uma boa opção: notas em Markdown, numa pasta sua. Depois é só informar a pasta aqui.',
299 setupObsidianLink: 'Obsidian (obsidian.md)',
300 setupSave: 'Salvar',
301 setupSaved: (path: string) => `✓ ${path}`,
302 setupSkipped: 'O grimório não vai oferecer este destino.',
303 setupProject: 'Quer cadastrar esta pasta como projeto?',
304 setupDone: 'Concluir',
305 // Comemoração e geral
306 langChanged: 'Língua alterada.',
307 langDenied: (why: string) => `Não deu para mudar a língua: ${why}`,
308 aboutBody: [
309 'Feito por Saulo Rocha, desenvolvedor (Angular, .NET e Azure DevOps).',
310 'A ideia: dar ao Claude Code uma HUD de RPG aconchegante, com os caranguejos Clawd multiclasse, sem mudar a saída clássica do Claude. Bater o olho e saber o que está acontecendo, não perder o objetivo, ler menos e transformar o que se aprende em conquistas.',
311 ],
312 credits: 'Agradecimentos: padrões de código de Daniel Ávila (claude-code-templates, MIT). Lista completa no README.',
313 linkedin: 'LinkedIn do Saulo',
314 github: 'Projeto no GitHub',
315 version: (v: string) => `versão ${v}`,
316}
317
318type Dict = typeof PT
319
320const EN: Dict = {
321 className: {
322 druida: 'Clawd',
323 mago: 'Mage',
324 ilusionista: 'Illusionist',
325 artifice: 'Artificer',
326 paladino: 'Paladin',
327 patrulheiro: 'Ranger',
328 anao: 'Dwarf miner',
329 arauto: 'Herald',
330 alquimista: 'Alchemist',
331 },
332 section: {
333 objetivo: 'Objective',
334 quests: 'Chapter quests',
335 grupo: 'Party',
336 inventario: 'Inventory',
337 relatorio: 'Mission report',
338 rodape: 'Footer',
339 },
340 module: {
341 diario: ['Logbook', 'Mascot, rail and groups on tool rows'],
342 faixa: ['Band above the prompt', 'The pet, the current action and the objective in miniature'],
343 painel: ['Pane', 'Objective, quests, party, inventory and footer'],
344 campanha: ['Campaign', 'Objective, chapters and the celebration at the end of a mission'],
345 guarda: ['Protected-file guard', 'Asks before editing .env, pipelines and the like (and before commands that write them)'],
346 mochila: ['Backpack', 'Compaction card when the context fills up'],
347 grimorio: ['Grimoire', 'Lessons for CLAUDE.md, AGENTS.md, vault and ai-memory'],
348 sideQuest: ['Side quest', 'A parallel session with the campaign context'],
349 comemoracao: ['Celebration', 'The Clawd cheers when a mission ends'],
350 projetos: ['Projects', 'Pinned projects, their folders and Claude’s access to them'],
351 },
352 tokens: 'may spend tokens (only on your click)',
353 paneNotPlaced: why => `The pane did not open (${why}). Widen the Claude Code window.`,
354 profile: { completo: 'Full', foco: 'Focus', minimo: 'Minimal', personalizado: 'Custom' },
355 rarity: {
356 lendario: 'Legendary: protected (.env, pipelines, secrets)',
357 raro: 'Rare: critical (API, database, auth, build)',
358 incomum: 'Uncommon: code and screens',
359 comum: 'Common: tests, docs and light config',
360 pobre: 'Poor: deleted',
361 },
362 noObjective: 'The objective shows up with the first message of the session.',
363 setupMemoryTitle: 'Memory across sessions',
364 setupTagline: 'Four quick steps. You can change everything later in ⚙.',
365 knownTypes: { explore: 'Explorer', plan: 'Architect', 'claude-code-guide': 'Claude Code guide', 'statusline-setup': 'Status line' },
366 ago: s => (s < 90 ? 'now' : s < 3600 ? `${Math.round(s / 60)} min ago` : s < 86400 ? `${Math.round(s / 3600)} h ago` : `${Math.round(s / 86400)} d ago`),
367 catGeneral: 'General',
368 catPanel: 'Pane',
369 catMemory: 'Memory',
370 rowChat: 'In the chat',
371 rowPanel: 'In the pane',
372 rowTools: 'Tools',
373 rowGuard: 'Protection',
374 langAutoShort: 'Auto',
375 profileTitleShort: 'Profile',
376 showShort: 'Show',
377 readsRow: 'Reads',
378 guardRow: 'Protected',
379 grimoireRow: 'Grimoire',
380 haikuShort: 'Summarize with Haiku',
381 aboutTagline: v => `v${v} · a cozy RPG HUD for Claude Code`,
382 aboutBy: 'Made by',
383 authorRole: 'developer · Angular, .NET and Azure DevOps',
384 aboutFeatures: [
385 ['Objective always in sight', 'The first request becomes the campaign; quests and chapters show the way.'],
386 ['Party and expeditions', 'Each subagent becomes a hero; background ones show in the band.'],
387 ['Inventory by impact', 'Touched files get a rarity: protected is legendary.'],
388 ['Guard of the protected', '.env and pipelines ask for approval before changing.'],
389 ['Grimoire', 'Decisions and wins saved only on your click.'],
390 ],
391 campaignLabel: 'Campaign',
392 recalibrate: '✦ Recalibrate objective',
393 recalibrateDone: goal => `✦ Objective: ${goal}`,
394 recalibrateFail: 'Haiku did not answer; the objective stays as it was.',
395 recalibratePrompt: asks =>
396 `These are the requests the person made in this Claude Code session, from first to latest:
397${asks.map((a, n) => `${n + 1}. ${a}`).join('\n')}
398
399Write the CURRENT objective of the session in at most 8 words, in English, starting with a verb. Reply with the objective only, no quotes or final period.`,
400 chaptersTitle: 'Chapters',
401 chaptersDone: n => (n === 1 ? '1 chapter closed' : `${n} chapters closed`),
402 questsHint: 'Claude has not listed steps yet: the chapters tell the story.',
403 waitingYou: 'waiting for you',
404 atCamp: 'at camp · waiting for you',
405 resting: 'resting',
406 onExpedition: 'on an expedition',
407 partyHint: 'Subagents that Claude calls join the party.',
408 recentCommits: 'Latest commits',
409 bandIdle: 'Ready · waiting for you',
410 moreActive: n => `+${n} in action`,
411 expedition: (n, first) => (n === 1 ? `${first} on an expedition` : `${n} agents on an expedition`),
412 expeditionTip: lines => ['Running in the background:', ...lines].join('\n'),
413 failedShort: 'failed',
414 deleted: 'deleted',
415 compactShort: '🎒 Compact',
416 bandStep: (n, total) => `Step ${n} of ${total}`,
417 stepsWord: (_done, total) => (total === 1 ? 'step' : 'steps'),
418 chapterShort: n => `ch. ${n}`,
419 stepTip: (n, name, state) => `Step ${n} · ${name} — ${state === 'done' ? 'done' : state === 'now' ? 'in progress' : 'to do'}`,
420 rarityByImpact: 'Rarity by impact',
421 rarities: 'Rarities',
422 achieved: '✓ Objective reached',
423 markAchieved: 'Mark as reached',
424 noQuests: "No quests yet. They come from Claude's task list.",
425 questsMist: n => `+${n} in the mist`,
426 chapterLine: (n, origin) => (origin === '' ? `ch. ${n}` : `ch. ${n} · started as: ${origin}`),
427 questsDone: n => (n === 1 ? '1 done' : `${n} done`),
428 inProgress: 'in progress',
429 inAction: 'in action',
430 mainSession: cls => `main session · ${cls.toLowerCase()} form`,
431 missionDone: 'mission complete',
432 doneHint: 'done · hover for details',
433 noParty: 'Nobody in the party. Subagents show up here.',
434 partyDone: 'Done',
435 notRepo: 'Not inside a git repository.',
436 clean: 'Nothing touched yet.',
437 uncommitted: n => `● ${n} uncommitted`,
438 ahead: n => `↑${n}`,
439 behind: n => `↓${n}`,
440 actions: n => (n === 1 ? '1 action' : `${n} actions`),
441 isNew: 'new',
442 failed: n => `${n} failed`,
443 running: 'running',
444 expandGroup: 'Split',
445 splitHint: 'click Split to see them one by one',
446 footer: { mochila: 'Backpack', mana: 'Mana', vigor: 'Vigor' },
447 mochilaTip: p => `Backpack (context): ${p} used. Click to prepare a compaction.`,
448 manaTip: (p, at) => `Mana (5-hour window): ${p} left${at === '' ? '' : `, resets ${at}`}`,
449 vigorTip: (p, at) => `Vigor (weekly): ${p} left${at === '' ? '' : `, resets ${at}`}`,
450 resetAt: (clock, until) => (until === '' ? clock : `at ${clock} (in ${until})`),
451 off: name => `${name} is off in ⚙.`,
452 grimoire: '📖 Grimoire',
453 sideQuest: '⇄ Side quest',
454 config: '⚙',
455 about: 'ℹ',
456 back: '‹ Back',
457 configTitle: 'Settings',
458 aboutTitle: 'About',
459 language: 'Language',
460 langAuto: "Automatic (Claude's)",
461 skin: 'Skin',
462 skinEpico: 'Epic',
463 skinSobrio: 'Sober',
464 profileTitle: 'Pane profile',
465 show: 'Show in the pane',
466 modules: 'Modules',
467 diaryMode: 'Logbook',
468 grouped: 'Grouped',
469 separate: 'Separate',
470 diaryHint: 'In Grouped mode, Split › opens only that group; the rest stays grouped.',
471 guardMode: 'Protection',
472 guardModes: { nativo: 'Native dialog only', dialogo: "Claude's dialog", chefe: 'Boss encounter' },
473 guardHint: 'Native only adds a note to the permission dialog (when it shows). Dialog and boss always ask.',
474 haiku: '“Summarize with Haiku” button in the grimoire',
475 haikuButton: '✦ Summarize with Haiku',
476 haikuDone: n => `${n} inscription(s) summarized by Haiku.`,
477 join: '⊟ Join',
478 split: '⊞ Split',
479 guardQuestion: names => `Claude wants to change ${names}, which are protected. What should happen?`,
480 bossQuestion: names => `The Vault Guardian blocks ${names}. How do you pass?`,
481 guardHeader: 'Protection',
482 bossHeader: 'Vault',
483 guardHints: {
484 dialogo: [
485 'Claude writes the change as asked.',
486 'Blocks now and drafts a note in the prompt so you can adjust the change.',
487 'Blocks; the final answer and the report record the refusal.',
488 ],
489 chefe: [
490 'The mimic opens the chest: the change is written.',
491 'Blocks and drafts a note in the prompt; Claude waits for you to send it.',
492 'Blocks; the report records the refusal.',
493 ],
494 },
495 bossTitle: 'Vault Guardian',
496 bossSubtitle: 'Protected files ahead',
497 guardNotice: names => `🔒 ${names}: protected file (hero-journey)`,
498 denyRefuse: name => `${name} is protected and the person refused the change. Do not retry; say in the final answer that the change was left out.`,
499 denyNegotiate: name => `${name} is protected: the person wants to adjust the change first. Wait for their next message.`,
500 denyHeadless: name => `${name} is protected and nobody can approve it. Ask the person for the change.`,
501 denyFailed: "hero-journey's guard failed, so the change was blocked. Ask the person to make it.",
502 negotiateDraft: name => `About the change in ${name}: `,
503 reportRunning: 'in progress',
504 reportNone: 'The report shows up when the first mission ends.',
505 reportOf: (i, n) => `${i} of ${n}`,
506 chapter: n => `Chapter ${n}`,
507 result: 'Result',
508 risk: 'Risk',
509 next: 'Next',
510 waits: 'Waiting on you',
511 refused: 'Refused',
512 stats: (actions, files, added, removed, dur) =>
513 `${actions === 1 ? '1 action' : `${actions} actions`} · ${files === 1 ? '1 file' : `${files} files`} · +${added} −${removed} · ${dur}`,
514 approve: '✓ Approve',
515 adjust: '✎ Adjust',
516 approved: '✓ Approved',
517 adjustDraft: title => `Adjustment to the mission "${title}": `,
518 compactTitle: 'Backpack full',
519 compactBody: p => `Context is at ${p}. Send the instruction to the prompt and submit it when you like, or compact now while Claude is idle.`,
520 toPrompt: 'Send to prompt',
521 compactNow: 'Compact now',
522 compactBusy: 'Claude is working: compaction only runs while it is idle.',
523 compactDone: (before, after) => `Backpack tidied: ${before} → ${after}.`,
524 notNow: 'Not now',
525 filled: 'It is in the prompt. Send it when you like.',
526 noComposer: 'There is no prompt box here: the text was copied.',
527 grimTitle: 'Grimoire',
528 grimSubtitle: 'The Mage wrote down what is worth keeping',
529 grimCount: n => (n === 1 ? 'The Mage wrote 1 inscription for you to seal' : `The Mage wrote ${n} inscriptions for you to seal`),
530 grimEmpty: 'No pending inscriptions.',
531 grimKind: { decisao: 'Decision', conquista: 'Achievement', preferencia: 'Preference' },
532 grimWhy: {
533 decisao: 'A technical decision: goes to the vault. CLAUDE.md and AGENTS.md become standing instructions for Claude: tick them only if you mean it.',
534 conquista: 'An achievement with a number: goes to the career notes in your vault.',
535 preferencia: 'A preference for working with AI: goes to your ai-memory profile, which applies to every project.',
536 },
537 dest: { claudeMd: 'CLAUDE.md', agentsMd: 'AGENTS.md', vaultDecisao: 'Vault · decisions', carreira: 'Career', aiMemory: 'ai-memory' },
538 grimNoSafeDest: 'To keep this without turning it into a Claude instruction, set up a vault or ai-memory in ⚙ → ✦ First steps.',
539 grimNoneMarked: 'Tick at least one destination to seal.',
540 destWaiting: d => `⏳ ${d}: unavailable right now, written on the next Seal`,
541 seal: 'Seal',
542 discard: 'Discard',
543 sealed: n => (n === 1 ? '1 inscription sealed.' : `${n} inscriptions sealed.`),
544 notSealed: (n, why) =>
545 `${n === 0 ? 'A destination failed.' : n === 1 ? '1 inscription is still pending: destination unavailable or failed.' : `${n} inscriptions are still pending: destination unavailable or failed.`}${why === '' ? '' : ` ${why}`}`,
546 privacyBlocked: 'Left out of CLAUDE.md/AGENTS.md: the text mentions the vault or a personal path.',
547 decisionsHeading: 'Decisions',
548 grimSealedList: 'Sealed this session',
549 sqTitle: 'Side quest',
550 sqBody: 'Opens a parallel session for a detour, with this campaign’s summary. The request goes to the prompt; you finish the task and send it.',
551 copy: 'Copy summary',
552 copied: 'Summary copied.',
553 projects: '🗂 Projects',
554 projTitle: 'Projects',
555 projNote: 'The app’s sidebar cannot be changed by mods, so projects live here. Each project groups several folders; Claude only reaches the ones marked ✓.',
556 projCurrent: 'This project',
557 projNone: 'This folder is not part of any project yet.',
558 projAddCwd: '+ Add this folder as a project',
559 projPinned: 'Pinned',
560 projOthers: 'Others',
561 projEmpty: 'No projects yet.',
562 projNew: 'New project',
563 projNewPlaceholder: 'Project name',
564 projFolderPlaceholder: 'Path to another folder (e.g. ~/code/api)',
565 projAddFolder: 'Add',
566 projCreate: 'Create',
567 pin: '📌 Pin',
568 unpin: '📌 Unpin',
569 remove: '✕',
570 confirmRemove: '✕ Confirm remove?',
571 openSession: '↗ Open session',
572 accessNow: 'Access this session',
573 accessAlways: 'Always (in this folder)',
574 accessOk: 'Claude can reach it',
575 accessNo: 'no access',
576 accessSaved: n => `${n === 1 ? '1 folder' : `${n} folders`} allowed in .claude/settings.local.json.`,
577 accessFailed: why => `Could not write settings.local.json: ${why}`,
578 folderMissing: path => `The folder does not exist (or is a file): ${path}`,
579 folderNetwork: path => `Network paths are not supported: ${path}`,
580 setupTitle: 'First steps',
581 setupIntro: 'Two quick questions. You can change everything later in ⚙ or /config. ai-memory needs no folder: its MCP just has to be connected.',
582 setupOpen: '✦ First steps',
583 setupVaultQ: 'Do you use a vault (Obsidian or similar) for notes and career?',
584 setupYes: 'Yes',
585 setupNo: 'No',
586 setupPathVault: 'Vault path (e.g. C:\\Notes)',
587 setupPathInbox: 'Inbox folder inside the vault (default: Inbox)',
588 inboxInvalid: v => `“${v}” must be a subfolder of the vault, with no absolute path and no “..”.`,
589 setupMemoryOk: '✓ ai-memory connected',
590 setupMemoryTip:
591 'Recommended: ai-memory by Fabio Akita. It gives Claude long-term memory: it remembers what happened in your sessions and your preferences, across projects. With its MCP connected, the grimoire writes to it.',
592 setupMemoryLink: 'ai-memory on GitHub',
593 setupObsidianTip: 'No vault? Obsidian is a good option: Markdown notes in a folder you own. Then just enter the folder here.',
594 setupObsidianLink: 'Obsidian (obsidian.md)',
595 setupSave: 'Save',
596 setupSaved: path => `✓ ${path}`,
597 setupSkipped: 'The grimoire will not offer this destination.',
598 setupProject: 'Add this folder as a project?',
599 setupDone: 'Finish',
600 langChanged: 'Language changed.',
601 langDenied: why => `Could not change the language: ${why}`,
602 aboutBody: [
603 'Made by Saulo Rocha, a developer (Angular, .NET and Azure DevOps).',
604 "The idea: give Claude Code a cozy RPG HUD, with the multiclass Clawd crabs, without changing Claude's classic output. Glance and know what is going on, never lose the objective, read less, and turn what you learn into achievements.",
605 ],
606 credits: 'Thanks: code patterns by Daniel Ávila (claude-code-templates, MIT). Full list in the README.',
607 linkedin: "Saulo's LinkedIn",
608 github: 'Project on GitHub',
609 version: v => `version ${v}`,
610}
611
612export const DICT: Record<Lang, Dict> = { pt: PT, en: EN }
613
614export type { Dict }
615
616/**
617 * A língua da HUD. A opção do mod manda; em `auto`, vale a configuração
618 * `language` do Claude Code e depois o LANG do sistema; sem nenhum dos dois,
619 * a primeira mensagem da pessoa decide; por fim, inglês.
620 */
621export const resolveLang = (
622 option: unknown,
623 claudeLanguage: unknown,
624 systemLang: string | undefined,
625 firstPrompt: string | undefined,
626): Lang => {
627 if (option === 'pt-BR') return 'pt'
628 if (option === 'en') return 'en'
629 const named = typeof claudeLanguage === 'string' ? claudeLanguage.trim().toLowerCase() : ''
630 if (/^(pt|portugu)/.test(named)) return 'pt'
631 // Outra língua do Claude (japonês, espanhol...): a HUD só fala pt e en.
632 if (named !== '') return 'en'
633 if (systemLang !== undefined && /^pt/i.test(systemLang)) return 'pt'
634 if (systemLang !== undefined && /^en/i.test(systemLang)) return 'en'
635 if (firstPrompt !== undefined && looksPortuguese(firstPrompt)) return 'pt'
636 return 'en'
637}
638
639const PT_WORDS =
640 /(?<![\p{L}])(não|você|vc|para|pra|que|com|uma|um|isso|esse|essa|está|tá|então|também|arquivo|faça|faz|leia|fale|da|dos|das|os|roda|rodar|testes|veja|projeto|quantas|quantos|tem|como|mas|aqui|agora|vamos|pode|preciso|quero|meu|minha|ele|ela|seu|sua|pelo|pela|por favor|obrigad\p{L}*)(?![\p{L}])/giu
641
642/** Acento já basta; sem acento, duas ocorrências de palavras portuguesas (repetidas valem: "para… para…"). */
643export const looksPortuguese = (text: string) => /[ãõçáéíóúâêô]/i.test(text) || (text.match(PT_WORDS) ?? []).length >= 2
644hooks/core/prefs.ts 65 lines1// Preferências da HUD: perfis, seções do painel e os módulos que ligam e desligam.
2
3import type { ModuleId, Prefs, Profile, Section } from '../../types'
4
5/** Ordem em que o ⚙ mostra os módulos; `tokens` marca quem pode gastar. */
6export const MODULES: readonly { id: ModuleId; tokens: boolean }[] = [
7 { id: 'diario', tokens: false },
8 { id: 'faixa', tokens: false },
9 { id: 'painel', tokens: false },
10 { id: 'campanha', tokens: true },
11 { id: 'guarda', tokens: false },
12 { id: 'mochila', tokens: true },
13 { id: 'grimorio', tokens: true },
14 { id: 'sideQuest', tokens: false },
15 { id: 'comemoracao', tokens: false },
16 { id: 'projetos', tokens: false },
17]
18
19export const DEFAULT_PREFS: Prefs = {
20 profile: 'completo',
21 hidden: [],
22 diary: 'agrupado',
23 partyJoined: true,
24 skin: 'epico',
25 modules: {},
26 guardMode: 'dialogo',
27 grimorioHaiku: false,
28}
29
30/** Todo módulo nasce ligado; a pessoa desliga no ⚙. */
31export const isOn = (prefs: Prefs, id: ModuleId) => prefs.modules[id] !== false
32
33export const toggleModule = (prefs: Prefs, id: ModuleId): Prefs => ({
34 ...prefs,
35 modules: { ...prefs.modules, [id]: !isOn(prefs, id) },
36})
37
38// O relatório saiu do painel (2026-10-09): a seção fica no tipo só para ler prefs antigas.
39export const SECTIONS: readonly Section[] = ['objetivo', 'quests', 'grupo', 'inventario', 'rodape']
40
41/** O que cada perfil esconde do painel. O Personalizado guarda a escolha da pessoa. */
42export const PROFILE_HIDDEN: Record<Exclude<Profile, 'personalizado'>, Section[]> = {
43 completo: [],
44 foco: ['grupo', 'inventario'],
45 minimo: ['objetivo', 'quests', 'grupo', 'inventario'],
46}
47
48export const withProfile = (prefs: Prefs, profile: Profile): Prefs =>
49 profile === 'personalizado' ? { ...prefs, profile } : { ...prefs, profile, hidden: [...PROFILE_HIDDEN[profile]] }
50
51export const toggleSection = (prefs: Prefs, section: Section): Prefs => ({
52 ...prefs,
53 profile: 'personalizado',
54 hidden: prefs.hidden.includes(section) ? prefs.hidden.filter(s => s !== section) : [...prefs.hidden, section],
55})
56
57export const isShown = (prefs: Prefs, section: Section) => !prefs.hidden.includes(section)
58
59/** Preferências guardadas de uma versão antiga ganham os campos novos. */
60export const migratePrefs = (stored: unknown): Prefs => {
61 const s = (stored ?? {}) as Partial<Prefs>
62 const hidden = Array.isArray(s.hidden) ? s.hidden.filter((x): x is Section => SECTIONS.includes(x as Section)) : []
63 return { ...DEFAULT_PREFS, ...s, hidden, modules: { ...(s.modules ?? {}) } }
64}
65hooks/core/ui.tsx 68 lines1// O que toda tela usa: os elementos da superfície, o Svg só no desktop e o
2// título de seção nas duas peles. Nenhuma função aqui usa `$`.
3
4import type { EngineInterface } from 'claude-code'
5
6import type { Palette, Skin } from '../../types'
7import type { Art } from './art'
8import { heroTitle } from './hero-art'
9import type { Dict } from './i18n'
10
11export type Ui = ReturnType<EngineInterface['ui']['resolve']>
12type DesktopUi = Extract<Ui, { Svg: unknown }>
13
14export const GITHUB_URL = 'https://github.com/soulrocha/Claude-code-hero-journey'
15/** O LinkedIn do Saulo, no "Sobre". */
16export const LINKEDIN_URL = 'https://www.linkedin.com/in/soulrocha'
17export const VERSION = '0.4.0'
18
19export type Ctx = {
20 ui: Ui
21 desktop: boolean
22 t: Dict
23 palette: Palette
24 skin: Skin
25 width: number
26 /** O tom quente do painel; ausente quando o tema do Claude é automático (não dá para saber se é claro). */
27 bg?: string
28}
29
30/**
31 * O frame isolado do `Svg` interativo pinta o fundo branco quando o esquema de
32 * cor dele difere do app. Com o tema automático, segue o do sistema, como o app.
33 */
34export const withScheme = (source: string, c: Pick<Ctx, 'palette' | 'bg'>) => {
35 const scheme = c.bg === undefined ? 'light dark' : c.palette === 'claro' ? 'light' : 'dark'
36 return source.replace(/^<svg[^>]*>/, tag => `${tag}<style>:root{color-scheme:${scheme}}</style>`)
37}
38
39/** Svg só no desktop; nas outras superfícies, nada (quem chama põe o texto). */
40export const svg = (c: Ctx, a: Art, key?: string) => {
41 if (!c.desktop) return null
42 const { Svg } = c.ui as DesktopUi
43 return <Svg key={key ?? a.alt} source={withScheme(a.source, c)} width={a.width} height={a.height} alt={a.alt} isInteractive />
44}
45
46/**
47 * A largura útil em px: as colunas menos o recuo de 1 de cada lado, a 7,6 px
48 * por coluna (medido com a régua do laboratório no app desktop).
49 */
50export const pxOf = (c: Ctx) => Math.max(160, Math.round((c.width - 2) * 7.6))
51
52/** Título do quest log; `right` é um contador à direita (ex.: "3/8"). */
53export const title = (c: Ctx, text: string, mark?: '!' | '?', right?: string) => {
54 const { Text } = c.ui
55 return c.desktop ? (
56 svg(c, heroTitle(pxOf(c), text, c.palette, mark, right), `title-${text}`)
57 ) : (
58 <Text bold color="claude">
59 {c.skin === 'epico' ? `◆ ${mark === undefined ? '' : `${mark} `}${text} ◆` : text.toUpperCase()}
60 </Text>
61 )
62}
63
64type InputUi = Extract<Ui, { Input: unknown }>
65
66/** O campo de texto, nas superfícies que têm (desktop e terminal); senão, nada. */
67export const inputOf = (ui: Ui): InputUi['Input'] | undefined => ('Input' in ui ? (ui as InputUi).Input : undefined)
68hooks/modules/campanha/campanha.ts 152 lines1// Campanha e relatório da missão. A campanha é a sessão: o título e o objetivo
2// vêm da primeira mensagem, cada mensagem nova abre um capítulo, e o fim de cada
3// turno fecha o capítulo com um relatório. A linha do tempo do relatório se
4// inspira no session-time-machine, de Daniel Ávila (claude-code-templates, MIT) —
5// https://github.com/davila7/claude-code-templates/tree/main/cli-tool/components/mods/productivity/session-time-machine
6
7import type { Beat, Campaign, Chapter, DiaryEntry, Report } from '../../../types'
8
9const clean = (text: string) => text.replace(/\s+/g, ' ').trim()
10
11export const newCampaign = (id: string, prompt: string, at: number): Campaign => ({
12 id,
13 title: titleOf(prompt),
14 objective: cutAtWord(prompt, 280),
15 startedAt: at,
16 achieved: false,
17 chapters: [{ n: 1, prompt: clean(prompt).slice(0, 140) }],
18})
19
20/** Corta na palavra, com reticências: nunca "…rode os test". */
21export const cutAtWord = (text: string, max: number) => {
22 const t = clean(text)
23 if (t.length <= max) return t
24 const cut = t.slice(0, max - 1)
25 const space = cut.lastIndexOf(' ')
26 return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, '')}…`
27}
28
29// Aberturas que não dizem o objetivo ("Ok.", "Por favor", "Primeiro").
30const OPENERS = /^(ok|okay|beleza|certo|bom|então|entao|oi|olá|ola|e aí|e ai|por favor|please|hey|hi|primeiro|first)[\s,.!:]+/i
31
32/**
33 * O objetivo curto, sem gastar tokens: a primeira frase do pedido sem as
34 * aberturas, cortada na palavra em até 70 caracteres. O inteiro vai no tooltip.
35 */
36export const shortGoal = (prompt: string) => {
37 let t = clean(prompt)
38 for (let i = 0; i < 3 && OPENERS.test(t); i++) t = t.replace(OPENERS, '')
39 const first = t.split(/(?<=[.!?])\s/)[0] ?? t
40 const goal = first.length >= 12 ? first : t
41 return cutAtWord(goal.charAt(0).toUpperCase() + goal.slice(1), 70)
42}
43
44/** O objetivo para mostrar: o título que o app deu à sessão, a Missão da 1ª resposta ou o shortGoal. */
45export const goalOf = (c: Campaign) => c.short ?? shortGoal(c.objective)
46
47/** Título curto: a primeira frase da mensagem, até 60 caracteres. */
48export const titleOf = (prompt: string) => {
49 const first = clean(prompt).split(/(?<=[.!?])\s/)[0] ?? ''
50 return first.length > 60 ? `${first.slice(0, 59)}…` : first
51}
52
53/** Uma mensagem nova: a primeira cria a campanha, as outras abrem capítulos. */
54export const onPrompt = (c: Campaign | null, id: string, prompt: string, at: number): Campaign => {
55 if (c === null) return newCampaign(id, prompt, at)
56 const n = c.chapters.length + 1
57 return { ...c, chapters: [...c.chapters, { n, prompt: clean(prompt).slice(0, 140) }] }
58}
59
60export const currentChapter = (c: Campaign | null): Chapter | undefined => c?.chapters[c.chapters.length - 1]
61
62/** O capítulo de um turno. Um turno sem mensagem nova (continuação) não tem capítulo. */
63export const chapterOf = (c: Campaign | null, turnId: string): Chapter | undefined => c?.chapters.find(ch => ch.turnId === turnId)
64
65/**
66 * Liga o turno que começa ao capítulo da mensagem dele: o primeiro capítulo sem
67 * turno cujo texto bate com o do turno. Uma mensagem digitada durante outro
68 * turno espera o seu próprio; um turno de continuação não pega capítulo nenhum.
69 */
70export const assignTurn = (c: Campaign, turnId: string, text: string): Campaign => {
71 const said = clean(text)
72 const ch = c.chapters.find(x => x.turnId === undefined && said !== '' && said.startsWith(x.prompt.replace(/…$/, '')))
73 return ch === undefined ? c : { ...c, chapters: c.chapters.map(x => (x.n === ch.n ? { ...x, turnId } : x)) }
74}
75
76/** As linhas do formato briefing-de-bordo: Missão, Resultado, Risco, Próximo, Aguarda você. */
77export const briefingOf = (answer: string) => {
78 const field = (...names: string[]) => {
79 for (const name of names) {
80 const m = new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`, 'i').exec(answer)
81 if (m?.[1] !== undefined) return clean(m[1]).slice(0, 200)
82 }
83 return undefined
84 }
85 return {
86 mission: field('Missão', 'Mission'),
87 result: field('Resultado', 'Result'),
88 risk: field('Risco', 'Risk'),
89 next: field('Próximo', 'Next'),
90 waits: field('Aguarda você', 'Waiting on you'),
91 }
92}
93
94/** O relatório de um capítulo, montado do diário do turno e da resposta final. */
95export const buildReport = (
96 chapter: Chapter,
97 entries: readonly DiaryEntry[],
98 answer: string,
99 durationMs: number,
100 reason: string,
101 refusals: readonly string[],
102): Report => {
103 const b = briefingOf(answer)
104 const files = new Set(entries.filter(e => /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(e.tool)).map(e => e.summary))
105 const beats: Beat[] = entries.map(e => ({ cls: e.cls, tool: e.tool, summary: e.summary, status: e.status, ms: e.ms }))
106 return {
107 chapter: chapter.n,
108 title: b.mission ?? chapter.prompt.slice(0, 80),
109 mission: b.mission,
110 result: b.result,
111 risk: b.risk,
112 next: b.next,
113 waits: b.waits,
114 durationMs,
115 reason,
116 actions: entries.length,
117 files: files.size,
118 added: entries.reduce((n, e) => n + (e.added ?? 0), 0),
119 removed: entries.reduce((n, e) => n + (e.removed ?? 0), 0),
120 created: entries.filter(e => e.isNew === true).length,
121 failed: entries.filter(e => e.status === 'err').length,
122 refusals: [...refusals],
123 beats: beats.slice(-40),
124 }
125}
126
127/** Fecha o capítulo atual com o relatório. */
128export const closeChapter = (c: Campaign, report: Report): Campaign => ({
129 ...c,
130 chapters: c.chapters.map(ch => (ch.n === report.chapter ? { ...ch, report } : ch)),
131})
132
133export const reports = (c: Campaign | null): Report[] =>
134 (c?.chapters ?? []).map(ch => ch.report).filter((r): r is Report => r !== undefined)
135
136export const setVerdict = (c: Campaign, chapter: number, verdict: Report['verdict']): Campaign => ({
137 ...c,
138 chapters: c.chapters.map(ch => (ch.n === chapter && ch.report !== undefined ? { ...ch, report: { ...ch.report, verdict } } : ch)),
139})
140
141/** Cada campanha numa chave própria do `$.store`, para duas sessões não sobrescreverem uma à outra. */
142export const storeKey = (id: string) => `campanha:${id}`
143
144/** As chaves de campanha a apagar para guardar só as 20 mais recentes. */
145export const prune = (stored: readonly { key: string; startedAt: number }[], keep = 20) =>
146 [...stored].sort((a, b) => b.startedAt - a.startedAt).slice(keep).map(x => x.key)
147
148export const fmtDuration = (ms: number) => {
149 const s = Math.round(ms / 1000)
150 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}min ${String(s % 60).padStart(2, '0')}s`
151}
152hooks/modules/comemoracao/comemoracao.ts 19 lines1// Comemoração: o Clawd festeja no fim de um turno que trabalhou de verdade.
2// Toast, não som: no Windows o `$.audio` não toca (handoff, seção 4).
3
4import type { Lang, Report } from '../../../types'
5
6/**
7 * Só comemora uma missão declarada (a linha **Missão:** da resposta, nunca o
8 * pedido da pessoa) que mexeu em arquivos, sem falha. Turno que só conversou,
9 * leu ou lançou um subagente não vira "Missão cumprida".
10 */
11export const celebrate = (r: Report, lang: Lang) => {
12 if (r.reason !== 'answer' || r.failed > 0 || r.mission === undefined) return undefined
13 if (r.files === 0 && r.added + r.removed === 0 && r.created === 0) return undefined
14 const stats = [r.added + r.removed > 0 ? `+${r.added} −${r.removed}` : '', r.created > 0 ? `${r.created} ${lang === 'pt' ? 'novo(s)' : 'new'}` : '']
15 .filter(Boolean)
16 .join(' · ')
17 return `🦀✦ ${lang === 'pt' ? 'Missão cumprida' : 'Mission complete'}: ${r.mission}${stats === '' ? '' : ` (${stats})`}`
18}
19hooks/modules/diario/diario.ts 45 lines1// Diário de bordo: como as chamadas se agrupam e o que o resumo do grupo diz.
2// Padrão de embrulhar o ToolUse e medir o tempo: tool-timing-badge, de Daniel
3// Ávila (claude-code-templates, MIT) — https://github.com/davila7/claude-code-templates/tree/main/cli-tool/components/mods/ui/tool-timing-badge
4
5import type { Cls, DiaryEntry, Prefs } from '../../../types'
6
7/**
8 * O grupo de uma nova linha: entra no grupo da linha anterior quando é da
9 * mesma classe, do mesmo turno, e esse grupo não foi separado pela pessoa.
10 */
11export const groupFor = (diary: readonly DiaryEntry[], cls: Cls, turnId: string, id: string, expanded: readonly string[]) => {
12 const last = diary[diary.length - 1]
13 if (last !== undefined && last.cls === cls && last.turnId === turnId && !expanded.includes(last.group)) return last.group
14 return id
15}
16
17export type GroupView =
18 | { kind: 'single' }
19 | { kind: 'head'; entries: DiaryEntry[] }
20 | { kind: 'hidden' }
21
22/**
23 * Como desenhar a linha `id` no modo agrupado: sozinha, como cabeça do grupo
24 * (com o resumo) ou escondida (dentro de um grupo recolhido). Um grupo com
25 * alguma chamada ainda rodando fica aberto, para não esconder o que acontece.
26 */
27export const groupView = (diary: readonly DiaryEntry[], id: string, mode: Prefs['diary'], expanded: readonly string[]): GroupView => {
28 if (mode === 'separado') return { kind: 'single' }
29 const entry = diary.find(e => e.id === id)
30 if (entry === undefined || expanded.includes(entry.group)) return { kind: 'single' }
31 const entries = diary.filter(e => e.group === entry.group)
32 if (entries.length < 2 || entries.some(e => e.status === 'run')) return { kind: 'single' }
33 return entries[0]?.id === id ? { kind: 'head', entries } : { kind: 'hidden' }
34}
35
36/** Resumo do grupo pelos resultados: "2 ações · ✓ novo · ✓ +42 −8". */
37export const groupStats = (entries: readonly DiaryEntry[]) => {
38 const created = entries.filter(e => e.isNew === true).length
39 const failed = entries.filter(e => e.status === 'err').length
40 const added = entries.reduce((n, e) => n + (e.added ?? 0), 0)
41 const removed = entries.reduce((n, e) => n + (e.removed ?? 0), 0)
42 return { count: entries.length, created, failed, added, removed }
43}
44
45hooks/modules/diario/view.tsx 79 lines1// Diário de bordo, enxuto: o pet de 28 px com o trilho na cor da classe, a linha
2// do próprio app e o resultado em símbolo + texto curto, sem pílula. Um grupo
3// recolhido é uma linha só: "Mago · 3 ações ✓ 3 · Separar ▾".
4
5import type { RenderElement } from 'claude-code'
6
7import type { DiaryEntry } from '../../../types'
8import { classColor } from '../../core/art'
9import { diaryPet, pillOutline, statusColor } from '../../core/hero-art'
10import type { Ctx } from '../../core/ui'
11import { svg } from '../../core/ui'
12import { groupStats } from './diario'
13
14/** O texto curto ao lado do símbolo: "novo", "+12 −3", "falhou", "em execução" ou o tempo. */
15export const resultWords = (c: Ctx, entry: Pick<DiaryEntry, 'status' | 'ms' | 'added' | 'removed' | 'isNew'>) => {
16 if (entry.status === 'run') return c.t.running
17 if (entry.status === 'err') return c.t.failedShort
18 if (entry.isNew === true) return c.t.isNew
19 if (entry.added !== undefined && entry.added + (entry.removed ?? 0) > 0) return `+${entry.added} −${entry.removed ?? 0}`
20 if (entry.ms !== undefined && entry.ms >= 1000) return `${(entry.ms / 1000).toFixed(1)}s`
21 return ''
22}
23
24export const groupHeadView = (c: Ctx, entries: readonly DiaryEntry[], split: () => void): RenderElement => {
25 const { Box, Button, Text } = c.ui
26 const first = entries[0]
27 const cls = first?.cls ?? 'druida'
28 const s = groupStats(entries)
29 const state = s.failed > 0 ? 'err' : 'ok'
30 const parts = [c.t.actions(s.count)]
31 if (s.added + s.removed > 0) parts.push(`+${s.added} −${s.removed}`)
32 const tip = `${c.t.className[cls]} · ${parts.join(' · ')}`
33 return (
34 <Box flexDirection="row" gap={1} alignItems="center">
35 {svg(c, diaryPet(cls, state, tip, c.palette), 'mark')}
36 <Box flexDirection="row" gap={1} flexGrow={1} flexShrink={1}>
37 <Text bold color={classColor(cls, c.palette)}>
38 {c.t.className[cls]}
39 </Text>
40 <Text dimColor wrap="truncate-end">
41 · {parts.join(' · ')}
42 </Text>
43 </Box>
44 {svg(c, pillOutline(state, `${s.count - s.failed}/${s.count}`, c.palette), 'pill')}
45 <Button key={`split-${first?.group ?? ''}`} plain dimColor label={`${c.t.expandGroup} ▾`} onPress={split} />
46 </Box>
47 )
48}
49
50export const rowView = (
51 c: Ctx,
52 entry: Pick<DiaryEntry, 'cls' | 'status' | 'summary' | 'ms' | 'added' | 'removed' | 'isNew'>,
53 row: RenderElement,
54): RenderElement => {
55 const { Box, Text } = c.ui
56 const name = c.t.className[entry.cls]
57 const words = resultWords(c, entry)
58 const color = statusColor(entry.status, c.palette)
59 if (!c.desktop) {
60 return (
61 <Box flexDirection="row" gap={1}>
62 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
63 {row}
64 </Box>
65 <Text color={color}>{`${entry.status === 'run' ? '…' : entry.status === 'err' ? '✗' : '✓'}${words === '' ? '' : ` ${words}`}`}</Text>
66 </Box>
67 )
68 }
69 return (
70 <Box flexDirection="row" gap={1} alignItems="center">
71 {svg(c, diaryPet(entry.cls, entry.status, `${name} · ${entry.summary}`, c.palette), 'mark')}
72 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
73 {row}
74 </Box>
75 {svg(c, pillOutline(entry.status, entry.status === 'run' ? '' : words, c.palette), 'pill')}
76 </Box>
77 )
78}
79hooks/modules/faixa/view.tsx 118 lines1// Faixa acima do prompt, enxuta: o pet (com o acessório enquanto trabalha), o
2// item na mão (o arquivo em uso, na cor da raridade), a ação curta e o objetivo
3// em miniatura. O ◈ no canto abre e fecha o painel sem mover nada. Nunca
4// repete o pedido da pessoa.
5
6import type { RenderElement } from 'claude-code'
7
8import type { Campaign, Member, Quest, Working } from '../../../types'
9import { classColor } from '../../core/art'
10import type { Step } from '../../core/hero-art'
11import { bandArt } from '../../core/hero-art'
12import type { Ctx } from '../../core/ui'
13import { svg } from '../../core/ui'
14import { humanType } from '../../core/classes'
15import { questView, rarityOf } from '../painel/painel'
16
17/**
18 * Os passos da campanha: as quests (tarefas do Claude) ou, sem elas, os capítulos.
19 * Capítulo fechado (com relatório) é passo feito; o último aberto é o atual.
20 */
21export const campaignSteps = (c: Campaign | null, quests: readonly Quest[], chapterName: (n: number) => string): Step[] => {
22 if (quests.length > 0) return stepsOf(quests)
23 const chapters = c?.chapters ?? []
24 const lastOpen = [...chapters].reverse().find(ch => ch.report === undefined)
25 return chapters.map(ch => ({
26 name: ch.report?.mission ?? chapterName(ch.n),
27 state: ch.report !== undefined ? 'done' : ch === lastOpen ? 'now' : 'next',
28 }))
29}
30
31export const stepsOf = (quests: readonly Quest[]): Step[] => {
32 const current = questView(quests).current
33 return quests.map(q => ({
34 name: q.title,
35 state: q.status === 'completed' ? 'done' : q === current ? 'now' : 'next',
36 }))
37}
38
39/**
40 * `away`: subagentes rodando em segundo plano. Com o Claude parado e alguém em
41 * expedição, a faixa diz isso (nada de "Pronto" falso) e o tooltip lista cada um.
42 */
43export const bandView = (
44 c: Ctx,
45 now: Working | null,
46 quests: readonly Quest[],
47 campaign: Campaign | null,
48 toggle: () => void,
49 away: readonly Member[] = [],
50 intent = '',
51): RenderElement => {
52 const { Box, Button, Text } = c.ui
53 const name = now ? c.t.className[now.cls] : ''
54 const awayName = (m: Member) => humanType(m.type, c.t.knownTypes) || c.t.className[m.cls]
55 const idle = away.length > 0 ? c.t.expedition(away.length, awayName(away[0] as Member)) : c.t.bandIdle
56 const idleTip = away.length > 0 ? c.t.expeditionTip(away.map(m => `${awayName(m)} (${c.t.className[m.cls]}) — ${m.description}`)) : undefined
57 if (!c.desktop) {
58 // Terminal: uma linha só, sem Svg.
59 const goal = quests.length > 0 ? `${quests.filter(q => q.status === 'completed').length}/${quests.length}` : ''
60 return (
61 <Box key="band" flexDirection="row" gap={1}>
62 {now ? (
63 <Text bold color={classColor(now.cls, c.palette)}>
64 {name}
65 </Text>
66 ) : (
67 <Text dimColor>{idle}</Text>
68 )}
69 {now && (
70 <Text dimColor wrap="truncate-end">
71 · {now.summary}
72 </Text>
73 )}
74 <Box flexGrow={1} />
75 {goal !== '' && <Text dimColor>{goal}</Text>}
76 </Box>
77 )
78 }
79 // Nunca o texto do pedido: o passo atual (quest) ou o capítulo, com a trilha deles.
80 const steps = campaignSteps(campaign, quests, c.t.chapter)
81 const done = steps.filter(s => s.state === 'done').length
82 const now$ = steps.find(s => s.state === 'now')
83 const byQuests = quests.length > 0
84 const chapters = campaign?.chapters.length ?? 0
85 // Sem objetivo de verdade (nenhuma quest e um capítulo só), o bloco some e libera a faixa.
86 const goal =
87 steps.length === 0 || (!byQuests && steps.length < 2)
88 ? null
89 : {
90 label: byQuests ? `${done}/${steps.length}` : c.t.chapterShort(Math.max(1, chapters)),
91 current: now$ === undefined || now$.name === c.t.chapter(chapters) ? '' : now$.name,
92 tip: now$ ? `${byQuests ? c.t.bandStep(Math.min(done + 1, steps.length), steps.length) : c.t.chapter(chapters)}: ${now$.name}` : c.t.chapter(chapters),
93 // Todas as etapas: a arte conta todas e desenha só os 12 losangos mais recentes.
94 steps,
95 }
96 const w = Math.max(160, Math.round(c.width * 7.6))
97 // A raridade olha só o fim do caminho: uma pasta-ancestral "api" não torna tudo raro.
98 const tail = now?.file?.split(/[\\/]/).slice(-3).join('/')
99 const art = bandArt(
100 w,
101 // A ação: a frase que o Claude narrou antes das ferramentas (briefing de bordo) ou o resumo da ferramenta.
102 now ? { cls: now.cls, name, text: intent !== '' ? intent : now.summary, file: tail, rarity: tail === undefined ? undefined : rarityOf(tail) } : null,
103 idle,
104 goal,
105 c.palette,
106 26,
107 idleTip,
108 )
109 return (
110 <Box key="band" flexDirection="row">
111 {svg(c, art, 'band-art')}
112 <Box position="absolute" top={0} right={0}>
113 <Button key="hero-toggle" plain dimColor label="◈" onPress={toggle} />
114 </Box>
115 </Box>
116 )
117}
118hooks/modules/grimorio/grimorio.ts 213 lines1// Grimório: acha, sem chamar o modelo, os aprendizados de um turno (decisão
2// técnica, conquista com número, preferência de trabalho com IA) e sugere onde
3// guardar cada um. Nada é gravado sem o clique da pessoa. O Haiku só entra, se
4// ligado no ⚙, para reescrever um candidato numa frase.
5
6import type { Destination, Inscription, InscriptionKind } from '../../../types'
7
8// Na resposta do Claude, só decisão declarada ("decidimos", "ficou decidido"):
9// a palavra "decisão" solta aparece em pendências ("aguardo sua decisão") e tabelas.
10const DECISION = /\b(decidimos|ficou decidido|optamos|adotamos|padr[ãa]o adotado|we decided|we chose|it was decided)\b/i
11// Na mensagem da pessoa, regra que ela mesma estabelece. `(?=\W|$)`: "regra:" seguido de espaço não tem fronteira.
12const RULE = /\b(a partir de agora|de agora em diante|decidi|regra:|sempre use|nunca use|from now on|always use|never use)(?=\W|$)/i
13// Frases que falam de decisão sem tomar uma.
14const NOT_DECIDED = /\b(sua decis[ãa]o|nenhuma decis[ãa]o|aguard\w*|pendente|your decision|no decision|waiting)\b/i
15// Daqui em diante a resposta do briefing é pendência, risco ou próximo passo: não é decisão nem conquista.
16const OPEN_PART = /\*\*(Aguarda você|Sua vez|Próximo|Risco|Não verificado|Waiting on you|Your turn|Next|Risk|Not verified):\*\*/i
17
18/** A parte da resposta que fala do que foi feito: sem pendências, código, tabelas nem citações. */
19const settledPart = (answer: string) => {
20 const cut = answer.search(OPEN_PART)
21 return (cut === -1 ? answer : answer.slice(0, cut))
22 .replace(/```[\s\S]*?```/g, '')
23 .split('\n')
24 .filter(line => !/^\s*(\||>)/.test(line))
25 .join('\n')
26}
27
28/** A linha **Resultado:** do briefing, quando existe: é dela que sai a conquista. */
29const resultLine = (answer: string) => /\*\*(Resultado|Result):\*\*\s*(.+)/i.exec(answer)?.[2]
30// "aumentou" sozinho não é conquista ("o contexto aumentou para 60%"); "de X para Y" é.
31const IMPROVEMENT = /\b(reduz\w*|melhor\w*|caiu|cortou|acelerou|de [\d.,]+\s?\w* para [\d.,]+|reduced|improved|faster|dropped|from [\d.,]+\s?\w* to [\d.,]+)/i
32const NUMBER = /\d+([.,]\d+)?\s?(%|ms|s\b|x\b|vezes|min|segundos|minutos|horas|times|kb|mb)/i
33const PREFERENCE = /\b(prefiro|gosto (que|de)|n[ãa]o quero|me responda|sempre me|odeio|detesto|i prefer|i like|i don't want|always give me|never give me)\b/i
34
35const sentences = (text: string) =>
36 text
37 .replace(/\*\*/g, '')
38 .split(/(?<=[.!?])\s+|\n+/)
39 .map(s => s.replace(/^[-*•\d.)\s]+/, '').trim())
40 .filter(s => s.length >= 12 && s.length <= 280)
41
42const slug = (text: string) =>
43 text
44 .normalize('NFD')
45 .replace(/[̀-ͯ]/g, '')
46 .toLowerCase()
47 .replace(/[^a-z0-9]+/g, '-')
48 .replace(/^-|-$/g, '')
49 .slice(0, 40)
50
51/**
52 * Destinos sugeridos por tipo. CLAUDE.md e AGENTS.md viram instrução permanente
53 * do Claude: nunca vêm marcados, a pessoa marca se quiser.
54 */
55export const SUGGESTED: Record<InscriptionKind, readonly Destination[]> = {
56 decisao: ['vaultDecisao'],
57 conquista: ['carreira'],
58 preferencia: ['aiMemory'],
59}
60
61/** O servidor MCP do ai-memory e o `scope` que a versão dele aceita para preferência. */
62export type Memory = { server: string; scope: 'profile' | 'global' }
63
64/** Para onde dá para gravar nesta sessão: a pasta do vault e o ai-memory, se o MCP estiver conectado. */
65export type Dests = { vault: string; inbox?: string; memory?: Memory }
66
67/**
68 * Acha o `memory_write_page` do ai-memory entre as ferramentas da sessão. O
69 * servidor sai do nome da ferramenta, então vale com o servidor renomeado; as
70 * versões que têm o perfil entre projetos pedem `scope: "profile"` na descrição.
71 */
72export const memoryTool = (tools: readonly { name: string; description?: string }[]): Memory | undefined => {
73 for (const t of tools) {
74 const m = /^mcp__(.+)__memory_write_page$/.exec(t.name)
75 if (m?.[1] !== undefined) return { server: m[1], scope: /scope: "profile"/.test(t.description ?? '') ? 'profile' : 'global' }
76 }
77 return undefined
78}
79
80/** Os destinos que existem nesta sessão: sem a pasta ou sem o MCP, o destino some. */
81export const available = (dest: Destination, d: Dests) =>
82 dest === 'vaultDecisao' || dest === 'carreira' ? d.vault !== '' : dest === 'aiMemory' ? d.memory !== undefined : true
83
84/**
85 * A linha que vai para CLAUDE.md e AGENTS.md: uma linha só, sem crase nem tag,
86 * com no máximo 280 caracteres. Não neutraliza o sentido de uma frase; a
87 * proteção é vir desmarcado e exigir o clique.
88 */
89export const oneLine = (s: string) => {
90 // `<` e `>` viram ‹ ›: `<system>` deixa de ser marcação, e `List<String>` ou `x < 10` não perdem o sentido.
91 const c = s.replace(/</g, '‹').replace(/>/g, '›').replace(/`/g, ' ').replace(/\s+/g, ' ').trim()
92 return c.length > 280 ? `${c.slice(0, 279)}…` : c
93}
94
95/**
96 * A pasta de entrada fica dentro do vault: nada de caminho absoluto, drive, caminho
97 * de rede ou `..`. Vazia vale (é o padrão, Inbox).
98 */
99export const isSubfolder = (sub: string) => !/^([a-z]:|[\\/])/i.test(sub) && !sub.split(/[\\/]/).includes('..')
100
101/** `nota.md` → `nota-2.md`: o nome livre seguinte, para nunca sobrescrever uma nota. */
102export const withSuffix = (path: string, n: number) => path.replace(/(\.md)?$/, `-${n}$1`)
103
104/** Candidatos de um turno: decisões e conquistas na resposta, preferências na mensagem da pessoa. */
105export const detect = (answer: string, prompt: string, turnId: string): Inscription[] => {
106 const found: Inscription[] = []
107 const add = (kind: InscriptionKind, text: string, source: string) => {
108 if (found.some(f => f.text === text)) return
109 found.push({ id: `${turnId}-${found.length}-${slug(text)}`, kind, text, source, destinations: [...SUGGESTED[kind]] })
110 }
111 for (const s of sentences(settledPart(answer))) {
112 if (s.includes('?') || NOT_DECIDED.test(s)) continue
113 if (DECISION.test(s)) add('decisao', s, 'resposta')
114 }
115 // Conquista: um número que melhorou. Com o briefing, só da linha Resultado.
116 const result = resultLine(answer)
117 for (const s of sentences(result ?? settledPart(answer))) {
118 if (!s.includes('?') && NUMBER.test(s) && IMPROVEMENT.test(s)) add('conquista', s, 'resposta')
119 }
120 for (const s of sentences(prompt)) {
121 if (s.includes('?')) continue
122 if (RULE.test(s)) add('decisao', s, 'mensagem')
123 else if (PREFERENCE.test(s)) add('preferencia', s, 'mensagem')
124 }
125 return found.slice(0, 4)
126}
127
128/** Junta candidatos novos aos pendentes, sem repetir texto já visto. */
129export const merge = (current: readonly Inscription[], fresh: readonly Inscription[]) => {
130 const seen = new Set(current.map(i => i.text))
131 return [...current, ...fresh.filter(i => !seen.has(i.text))].slice(-30)
132}
133
134export const pending = (list: readonly Inscription[]) => list.filter(i => i.sealed === undefined)
135
136export const toggleDest = (list: readonly Inscription[], id: string, dest: Destination): Inscription[] =>
137 list.map(i =>
138 i.id !== id
139 ? i
140 : { ...i, destinations: i.destinations.includes(dest) ? i.destinations.filter(d => d !== dest) : [...i.destinations, dest] },
141 )
142
143const today = (at: number) => new Date(at).toISOString().slice(0, 10)
144
145export type Write = { dest: Destination; path: string; text: string; append: boolean; scope?: 'profile' | 'global' }
146
147/**
148 * O que gravar para uma inscrição: caminho, texto e se é acréscimo. Para
149 * CLAUDE.md e AGENTS.md o texto é só a decisão: nunca o vault nem caminho
150 * pessoal. No ai-memory o caminho é o da página (gravada pelo MCP), com a hora
151 * no nome, porque `memory_write_page` substitui a página de mesmo caminho.
152 */
153export const writesFor = (i: Inscription, d: { repo: string } & Dests, at: number): Write[] => {
154 const join = (...parts: string[]) => parts.join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
155 const date = today(at)
156 const note = (title: string) => `---\ntipo: ${title}\ndata: ${date}\norigem: hero-journey\n---\n\n${i.text}\n`
157 const out: Write[] = []
158 for (const dest of i.destinations) {
159 if (!available(dest, d)) continue
160 if (dest === 'claudeMd' || dest === 'agentsMd') {
161 if (mentionsPrivate(i.text, d) || oneLine(i.text) === '') continue
162 out.push({ dest, path: join(d.repo, dest === 'claudeMd' ? 'CLAUDE.md' : 'AGENTS.md'), text: `- ${oneLine(i.text)} (${date})\n`, append: true })
163 } else if (dest === 'vaultDecisao') {
164 out.push({ dest, path: join(d.vault, d.inbox ?? 'Inbox', `decisao-pendente-${date}-${slug(i.text)}.md`), text: note('decisao'), append: false })
165 } else if (dest === 'carreira') {
166 out.push({ dest, path: join(d.vault, d.inbox ?? 'Inbox', `resultado-pendente-${date}-${slug(i.text)}.md`), text: note('resultado'), append: false })
167 } else {
168 const hms = new Date(at).toISOString().slice(11, 19).replace(/:/g, '')
169 const folder = i.kind === 'preferencia' ? 'workflow' : i.kind === 'decisao' ? 'decisions' : 'notes'
170 out.push({
171 dest,
172 path: `${folder}/${date}-${slug(i.text)}-${hms}.md`,
173 text: `# ${oneLine(i.text).slice(0, 80)}\n\n${i.text}\n`,
174 append: false,
175 ...(i.kind === 'preferencia' && d.memory !== undefined ? { scope: d.memory.scope } : {}),
176 })
177 }
178 }
179 return out
180}
181
182/** Regra de privacidade do vault: nada pessoal vai para arquivos do repositório. */
183export const mentionsPrivate = (text: string, paths: { vault: string }) => {
184 const lower = text.toLowerCase().replace(/\\/g, '/')
185 const hit = (p: string) => p !== '' && lower.includes(p.toLowerCase().replace(/\\/g, '/'))
186 return (
187 hit(paths.vault) ||
188 /\b(vault|obsidian|ai-memory)\b/i.test(text) ||
189 // Qualquer caminho absoluto pessoal, em qualquer posição: C:\..., D:\..., /Users/x,
190 // /home/x, /root/, /mnt/c/..., ~/ e caminhos de rede \\servidor\pasta.
191 /\b[a-z]:[\\/]/i.test(text) ||
192 /(\/users\/|\/home\/|\/root\/|\/mnt\/[a-z]\/|(^|[\s"'`(])~\/|\\\\[\w.-]+\\)/i.test(text)
193 )
194}
195
196/** Acrescenta a linha sob "## Decisões" (ou cria a seção no fim). */
197export const appendDecision = (current: string, line: string, heading: string) => {
198 if (current.includes(line.trim())) return current
199 const marker = `## ${heading}`
200 const at = current.indexOf(marker)
201 if (at === -1) return `${current.replace(/\s*$/, '')}${current.trim() === '' ? '' : '\n\n'}${marker}\n\n${line}`
202 const end = current.indexOf('\n## ', at + marker.length)
203 const head = end === -1 ? current.replace(/\s*$/, '') : current.slice(0, end).replace(/\s*$/, '')
204 const tail = end === -1 ? '' : current.slice(end)
205 return `${head}\n${line}${tail}`
206}
207
208/** O pedido ao Haiku (só com o ⚙ ligado): uma frase, sem inventar. */
209export const haikuPrompt = (i: Inscription, lang: 'pt' | 'en') =>
210 lang === 'pt'
211 ? `Reescreva como uma única frase curta e objetiva, sem inventar nada e sem citar caminhos de arquivo pessoais. Tipo: ${i.kind}. Texto: "${i.text}". Responda só com a frase.`
212 : `Rewrite as one short, objective sentence, inventing nothing and citing no personal file paths. Kind: ${i.kind}. Text: "${i.text}". Answer with the sentence only.`
213