SLOPSHOPPER

hero-journey

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

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hero-journey
│ ┃ Hero Journey ✕ › fix the failing auth test and add an audit log call │ ┃ ‹ Back │ ┃ ⏺ Read(src/auth.ts) │ ┃ ◆ Projects ◆ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ The app’s sidebar cannot be changed by mods, ⎿ Added 2 lines, removed 1 line │ ┃ so projects live here. Each project groups ⏺ Bash(bun test) │ ┃ several folders; Claude only reaches the ⎿ 3 pass, 1 fail │ ┃ ones marked ✓. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ This project │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ This folder is not part of any project yet. │ ┃ /work/app › /hero │ ┃ [ + Add this folder as a project ] │ ┃ │ ┃ No projects yet. │ ┃ │ ┃ New project: Project name ⏎ Create │ Ready · waiting for you ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⇄ Side quest │ │ 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. │ │ Campaign: fix the failing auth test and add an audit log call Objective: fix the failing auth test and add an │ │ audit log call Chapter 1: fix the failing auth test and add an audit log call Branch: ? · files touched: │ │ src/auth.ts ?? src/auth.test.ts │ │ [ Send to prompt ] [ Copy summary ] [ Not now ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Ready · waiting for you ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⇄ Side quest │ │ 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. │ │ Campaign: fix the failing auth test and add an audit log call Objective: fix the failing auth │ │ test and add an audit log call Chapter 1: fix the failing auth test and add an audit log │ │ call Branch: ? · files touched: src/auth.ts ?? src/auth.test.ts │ │ [ Send to prompt ] [ Copy summary ] [ Not now ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
Pane · Hero Journey
‹ Back ◆ Projects ◆ The app’s sidebar cannot be changed by mods, so projects live here. Each project groups several folders; Claude only reaches the ones marked ✓. This project This folder is not part of any project yet. /work/app [ + Add this folder as a project ] No projects yet. New project: Project name ⏎ Create
README

hero-journey 🦀

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.

Instalar

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.

Módulos

Cada módulo liga e desliga no ⚙ do painel → Módulos. Todos nascem ligados.

MóduloO que fazGasta tokens?
Diário de bordoEm 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 promptEnquanto 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órioA 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 protegidosAntes 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
MochilaQuando 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órioNo 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çãoNo 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

As 9 classes

ClasseFazAcessório
Clawd druidaSessão principal, planos e tarefasbrilhos
MagoLeitura, busca e pesquisalivro
IlusionistaUI, protótipos e designpincel
ArtíficeCódigoengrenagem
PaladinoTestes, lint e revisãolupa
PatrulheiroPipeline, deploy e logsluneta
Anão mineradorBanco de dados e migraçõespicareta
ArautoCommit, push, PR e mergecarimbo
AlquimistaIntegrações (MCP, HTTP)bolhas

Inventário: raridade pelo impacto

RaridadeArquivo
LendárioProtegido (segue a lista do guarda)
RaroCriado
IncomumModificado
PobreExcluído

O topo mostra o branch, ↑commits à frente e ● arquivos sem commit.

Configurações

Em /config (linhas do mod):

OpçãoPara quê
languageauto (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.
protectGlobs extras protegidos, separados por vírgula (ex.: **/secrets.json).
allowGlobs que o guarda nunca bloqueia (ex.: .env.example).
vaultPathPasta do vault (ex.: C:\Notas). Vazia, o grimório não oferece os destinos do vault e da carreira.
vaultInboxSubpasta do vault onde o grimório deixa as notas novas. Padrão: Inbox.

No ⚙ do painel (guardado entre as sessões):

  • língua;
  • módulos;
  • pele Épico ou Sóbrio;
  • perfil do painel (Completo, Foco, Mínimo ou Personalizado) e as seções que aparecem;
  • diário Agrupado ou Separado;
  • modo da proteção;
  • botão do Haiku no grimório.

Onde o grimório grava

TipoVem marcadoOnde grava
Decisão técnicaVault<vault>/<vaultInbox>/decisao-pendente-<data>-<tema>.md
Conquista com númeroCarreira<vault>/<vaultInbox>/resultado-pendente-<data>-<tema>.md
Preferência de IAai-memoryPerfil 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, desmarcadoCLAUDE.md e AGENTS.mdUma 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.
  • Nota nunca sobrescreve nota: se o nome já existe, a nova ganha -2, -3…
  • Só sai da lista o que gravou: uma inscrição sem nenhum destino gravado continua pendente, com o motivo no aviso.
  • O vault e a pasta de entrada são conferidos antes de gravar, inclusive quando vêm do /config ou do instalador: a pasta de entrada precisa ser uma subpasta do vault.
  • Só aparece o destino que dá para gravar: o vault com a pasta configurada, o ai-memory com o MCP conectado. Sem nenhum dos dois, o cartão oferece só CLAUDE.md e AGENTS.md, desmarcados, e lembra onde configurar. Selar sem nada marcado só avisa.
  • O destino ai-memory só aparece com o MCP do ai-memory conectado. O servidor é achado pelo nome da ferramenta (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.

Limites conhecidos

  • Guarda e terminal: o guarda pergunta sempre que um comando cita um caminho protegido, salvo leituras conhecidas (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.
  • Side quest: a API de mods não abre sessão. O mod prepara o pedido, e o Claude abre a sessão paralela pela ferramenta do app (se houver) ou por um subagente em segundo plano.
  • Mobile: o app móvel não tem campo de texto para mods. Lá, Projetos e Primeiros passos só mostram; para digitar pastas, use o desktop.
  • Barra lateral nativa: a API de mods não a alcança (não é um componente que o mod redesenha, e não há chamada para renomear ou agrupar sessões). Os projetos ficam no painel da HUD.
  • Tema automático: com o tema do Claude em automático, o mod não sabe se o app está claro ou escuro. Os Svgs usam a paleta escura e o painel fica sem o tom quente.
  • Ainda não verificado no app real (depende do spike, mods/clawd-hud-spike):
  • a animação do Svg no desktop;
  • se um grupo recolhido não deixa linha vazia;
  • o rodapé fixo;
  • o prompt.fill no desktop;
  • a leitura do tema;
  • o Svg acima do diálogo;
  • a descrição das opções do diálogo.

Agradecimentos

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 origemO que veio deleOnde está aqui
protected-paths-guardGlobs, o globToRegExp e o .catch que falha fechadomodules/guarda
large-edit-confirmationO $.ui.ask dentro do tool.call, o "Outro" não aprovar e negar sem ninguémmodules/guarda
tool-timing-badgeEmbrulhar o ToolUse e medir o tempomodules/diario
agent-flowAcompanhar subagentes pelo agent.spawnmodules/painel (Grupo)
git-sidebarGit por argv, sem shellmodules/painel (Inventário)
session-time-machineA linha do tempo da sessãomodules/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.

Mods de outros autores que combinam com a HUD

Estes não fazem parte da HUD: instale direto do repositório de quem fez, depois de ler o código.

ModPara quêComo instalar
secret-redactorEsconde segredosnpx claude-code-templates --mod security/secret-redactor
prompt-cache-controlMede o cachenpx claude-code-templates --mod observability/prompt-cache-control
rulebook-guard, blast-radius / Launch Codes, session-saver, md-previewAdotados no handoffLinks a confirmar

Não instale o protected-paths-guard junto com o guarda da HUD: ele nega direto, e a HUD pergunta.

Desenvolvimento

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:

CaminhoConteúdo
hooks/register.tsxLiga 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

English

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):

ModuleWhat it doesSpends tokens?
LogbookClass mascot and rail on tool rows, grouped runsNo
Band above the promptThe Clawd and Claude's stated intent while it worksNo
PaneObjective, quests, party, inventory, mission report, resource footerNo
Campaign and reportChapters and a mission report with a timeline, Approve and AdjustNo
Protected-file guardAsks 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
BackpackCompaction cardOnly on Compact now
GrimoireFinds 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 SealOnly through the optional Summarize with Haiku button
Side questDrafts a parallel-session request with the campaign summaryNo
ProjectsPinned projects with several folders; shows and grants Claude's access to each folderNo
CelebrationA toast when a mission endsNo

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.

Licença / License

MIT © 2026 Saulo Rocha. Os trechos adaptados mantêm o aviso em THIRD_PARTY_NOTICES.md.

Source 27 files
hooks/register.tsx 1287 lines
1// 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 lines
1// 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}
137
hooks/core/art.ts 393 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
393
hooks/core/i18n.ts 644 lines
1// 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
644
hooks/core/prefs.ts 65 lines
1// 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}
65
hooks/core/ui.tsx 68 lines
1// 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)
68
hooks/modules/campanha/campanha.ts 152 lines
1// 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}
152
hooks/modules/comemoracao/comemoracao.ts 19 lines
1// 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}
19
hooks/modules/diario/diario.ts 45 lines
1// 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
45
hooks/modules/diario/view.tsx 79 lines
1// 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}
79
hooks/modules/faixa/view.tsx 118 lines
1// 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}
118
hooks/modules/grimorio/grimorio.ts 213 lines
1// 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