SLOPSHOPPER

rainforest-mind

O problema não é falta de ideia, é o que recebe luz agora — planeja antes de implementar, responde em número, avisa no desvio e não chama de pronto sem a saída…

newpanebandguardcommandtoast
★ 3v1.53.1MITupdated 2026-10-09luisfmontes/rainforest-mind
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rainforest-mind
│ ┃ Esta sessão ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ┌──────────────────────────────────────────┐ │ rainforest-mind │ │ ┃ │ Fluxos em curso │ ● rainforest-mind: pai│ Deixado para depois: Escreveu "// TODO: │ │ ┃ │ Leitura dos fluxos indisponível agora; │ ● rainforest-mind: pai│ implement eviction later" em cache.ts │ │ ┃ │ tenta de novo no fim do próximo turno │ ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ └──────────────────────────────────────────┘ ⎿ Read 6 lines ╭─────────────────────────╮ │ ┃ ┌──────────────────────────────────────────┐ ⏺ Update(src/auth.ts) │ rainforest-mind │ │ ┃ │ Deixado para depois │ ⎿ Added 2 lines, removed 1 line │ Deixado para depois: OK │ │ ┃ │ D1 · em arquivo │ ⏺ Bash(bun test) ╰─────────────────────────╯ │ ┃ │ Escreveu "// TODO: implement │ ⎿ 3 pass, 1 fail │ ┃ │ persistence" em audit.ts │ │ ┃ │ [ Faz agora (só preenche o prompt) ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ D2 · em arquivo │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Escreveu "// TODO: implement eviction │ │ ┃ │ later" em cache.ts │ › /painel │ ┃ │ [ Faz agora (só preenche o prompt) ] │ ⎿ rainforest-mind: Painel aberto. │ ┃ │ │ ● rainforest-mind: painel: desvio: SyntaxError: JSON Parse error: Une │ ┃ │ D3 · segundo modelo │ │ ┃ │ OK │ │ ┃ │ [ Faz agora (só preenche o prompt) ] │ │ ┃ │ [ Limpar tudo ] │ │ ┃ │ Checagem do segundo modelo: ligada │ │ ┃ │ (/painel checar ligar|desligar) │ │ ┃ └──────────────────────────────────────────┘ │ ┃ ┌──────────────────────────────────────────┐ ○ pronto Tokens 98.9K Custo $0.42 Contexto 0 ━━━●─── 100 49% Deixado 3 Ferram./min 9 Turnos 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ pronto Tokens 98.9K Custo $0.42 Contexto 0 ━━━●─── 100 49% Deixado 3 Ferram./min 9 Turnos 1
Pane · Esta sessão
┌──────────────────────────────────────────────────────────┐ │ Fluxos em curso │ │ Leitura dos fluxos indisponível agora; tenta de novo no │ │ fim do próximo turno │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Deixado para depois │ │ D1 · em arquivo │ │ Escreveu "// TODO: implement persistence" em audit.ts │ │ [ Faz agora (só preenche o prompt) ] │ │ │ │ D2 · em arquivo │ │ Escreveu "// TODO: implement eviction later" em cache.ts │ │ [ Faz agora (só preenche o prompt) ] │ │ │ │ D3 · segundo modelo │ │ OK │ │ [ Faz agora (só preenche o prompt) ] │ │ [ Limpar tudo ] │ │ Checagem do segundo modelo: ligada (/painel checar │ │ ligar|desligar) │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Mapa da sessão │ │ Arquivos escritos │ │ ✓ /work/app/src/auth.ts │ │ ✓ /work/app/src/audit.ts │ │ ✓ /work/app/src/cache.ts │ │ Skills │ │ nenhuma │ │ Serviços MCP │ │ nenhum │ │ Subagentes chamados │ │ nenhum │ │ Escrita feita por Bash não é detectada; só Edit, Write e │ │ NotebookEdit entram aqui. │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Onde foi o contexto │ │ Usado 49% de 200.0K │ │ Medição indisponível │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Cache de prompt │ │ Agora quente, faltam 60:00 │ │ Próxima mensagem com o cache quente $0.02 │ │ Próxima mensagem com o cache frio $0.78 │ │ Estimativa: reenvio da conversa a preço de lista, cache │ │ de 1h │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Subagentes │ │ Nenhum iniciado ainda │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Custo e tokens │ │ Custo da sessão $0.42 │ │ Tokens lidos 97.4K │ │ servidos do cache 93% │ │ Tokens escritos 1.5K │ │ Turnos 1 │ │ Tokens e turnos contam desde que o mod carregou │ └──────────────────────────────────────────────────────────┘
README

<img src="assets/banner.svg" alt="rainforest-mind — memória de trabalho externa e radar de escopo" width="100%">

<img src="https://img.shields.io/badge/Claude_Code-plugin-2e8b57?style=flat-square" alt="Claude Code plugin"> <img src="https://img.shields.io/badge/vers%C3%A3o-1.53.1-1e5c3f?style=flat-square" alt="versão 1.53.1"> <img src="https://img.shields.io/badge/instala%C3%A7%C3%A3o-1_comando-6fcf97?style=flat-square" alt="uma instalação"> <img src="https://img.shields.io/badge/runtime-Node-9fd8ba?style=flat-square" alt="runtime Node">

O problema não é falta de ideia. É o que recebe luz agora.

Plugin de Claude Code que faz a sessão virar algo que dá pra ler e decidir. Quatro coisas, e nenhuma depende de você lembrar de ativar:

  • planeja antes de implementar — rodada de perguntas numeradas, cada uma já com a resposta recomendada, e então para e espera;
  • responde em número, não em parede de texto;
  • avisa quando a conversa sai do combinado — uma frase, com escolha;
  • não chama nada de pronto sem colar a saída que prova.

Duas dessas quatro têm trava que roda fora do modelo — comando com exit code, não instrução. Não dá pra argumentar com elas.

Instalação

claude plugin marketplace add luisfmontes/rainforest-mind
claude plugin install rainforest-mind@rainforest-mind

Ou aponte --plugin-dir para a pasta do repo em desenvolvimento.

Requisito único: Node no PATH. Nada mais. Detalhe do runtime, dos gêmeos em Python e do orçamento de contexto em docs/runtime-e-orcamento.md.

Claude Code 2.1, patch 287 em diante, para a abertura inteira. Nessa versão o plugin entrega as regras e as elaborações como seção do system prompt (hooks/register.ts). Sem ela, ou no Codex, a abertura vem pelo hook com o núcleo, como antes. A mesma versão desenha a barra de sessão acima do prompt (hooks/mod.tsx): estado, tokens, custo, contexto, cache, subagentes, erros e o relógio ⏰ dos avisos de jornada (regra 8) e de janela parada (regra 17), mais o comando /painel. O painel pede o Claude Code 2.1, patch 292 em diante, a versão em que foi medido, no terminal, nas duas contas; no desktop o desenho não foi medido. Sem o mod, não há barra, painel nem aviso por relógio, e nada mais muda. Parte do painel é adaptada do terminal-desk (MIT); crédito em NOTICE.

Não precisa configurar nada para começar. Quando quiser foco próprio num repositório, crie .rainforest/FOCO.md nele — é só isso.

Como é na prática

Você pede uma feature. Em vez de sair codificando, vem uma rodada numerada, cada pergunta já com a resposta recomendada:

❓ Q1 — Onde o token vive: sessão no servidor ou JWT no cliente? ➡️ Recomendo: sessão no servidor — você já tem Redis, e revogar JWT exige lista negra que é o mesmo trabalho com mais peças.

❓ Q2 — Expiração: 15 min com refresh, ou 8h fixas? ➡️ Recomendo: 8h fixas — refresh só se paga com múltiplos dispositivos, e não é o seu caso hoje.

E então para e espera. Você responde 1 ok, 2 não, usa 15 min — três palavras em vez de compor tudo do zero.

A regra que sustenta isso: descobrir fato é trabalho do assistente, nunca seu. O que está no arquivo, qual versão está instalada, o que o log diz — vira busca dele, não pergunta pra você.

E a emenda no fim do pedido não vira escopo em silêncio:

Você: faz a opção 2. Ah, e a gente podia colocar um cache nisso.

Sem o plugin: "Ótima ideia! Vou implementar a opção 2 com cache…"

Com o plugin: "Fechado: opção 2. Você adicionou o cache — entra no escopo agora ou planto?"

Por que floresta

Uma mente-floresta — o termo é de Paula Prober, em Your Rainforest Mind — não sofre de falta de ideia. Sofre do contrário: tudo cresce ao mesmo tempo, em direções diferentes, e o que cresce junto disputa a mesma luz.

Por isso lista de tarefas não resolve: lista pressupõe escassez de tarefa, e aqui a tarefa sobra. O problema não é lembrar do que fazer — é decidir o que recebe luz agora, e proteger essa decisão do resto.

PalavraO que é
focoa clareira onde você trabalha hoje — é contra ele, e só contra ele, que o desvio é medido
plantaro que não pode crescer agora vai pro chão com contexto, vivo, em vez de morrer num "algum dia"
colhervolta quando chega a hora, e vira trabalho de verdade
descartarsai da lista com motivo — a linha fica, porque ideia que sai sem rastro volta idêntica em três semanas
estaçãotempo certo é restrição real, não desculpa de quem procrastina

O resto do plugin não é botânico e não tenta ser: /foco, depurar e as travas se chamam pelo que fazem.

O fluxo: sete estágios que não dá para pular

flowchart LR
    A["arqueologia<br/>mapa.md"] -.->|"só se houver<br/>legado sem mapa"| B
    B["brainstorm<br/>design.md"] --> P["plano<br/>plano.md"]
    P --> E["executar<br/>agentes em paralelo"]
    E --> R["revisar<br/>contexto zerado"]
    R --> V["verificar<br/>roda o artefato"]
    V --> F["fechar<br/>commit + limpeza"]
    R -.->|"reprovado"| E
    V -.->|"reprovado"| E

Cada estágio é uma skill invocável sozinha — dá para entrar no meio, que é o caso normal de quem retoma trabalho.

O que faz isso ser fluxo e não conselho: cada estágio abre rodando estado.cjs exigir, e esse comando sai com código 2 quando o anterior não fechou.

$ node scripts/estado.cjs exigir --slug 2026-08-11-exemplo --estagio revisar
RECUSADO: 'revisar' exige executar fechado(s).
  executar: status=parcial
Rode o estagio 'executar' antes.

parcial e reprovado não fecham: "5 de 7 tarefas" para de virar "pronto" sem ninguém decidir isso.

Retomada é comando, não memória. Sessão nova roda estado.cjs proximo --slug <slug> e sabe onde parou. Design, plano e estado do fluxo são versionados de propósito — é por eles que outro dev pega a atividade no meio.

O título da sessão diz se o fluxo fechou

No /exit, o hook de SessionEnd hooks/titulo-sessao-end.cjs prefixa o título da sessão com o estado dos fluxos que ela tocou: [ok] quando todos fecharam, [aberto: <estágio>] quando algum ficou pela metade.

A busca do /resume casa contra o customTitle: digitar a palavra aberto filtra exatamente as sessões que ficaram pela metade — é por isso que o marcador é palavra, não símbolo.

O hook fica calado em três casos: reason diferente de prompt_input_exit (fechar o terminal na mão não dispara hook nenhum; claude -p emite other), sessão sem nenhum fluxo carimbado, e repositório que não usa o fluxo. Um nome dado com /rename tem precedência — o hook só prefixa, nunca substitui.

Não chama de pronto sem a saída

Esta é a que mais paga. Agente relata intenção, não resultado — e o relato é convincente exatamente quando está errado. Três cenas medidas, todas com suíte verde e relatório de sucesso completo:

  • O hash que não existia. Um agente reportou o commit que tinha criado: a009b5b4e8f0d83e3ef4e3d8e7f3e4d8e7f3e4d8. Olhe o fim da string — e7f3e4d8 aparece duas vezes seguidas. É um hash inventado, e dava pra ver sem consultar nada.
  • O critério afrouxado em vez de cumprido. Outro cumpriu todos os números do briefing, honestamente — 40 passed, exit 0 no lint — desligando as duas regras de lint que falhavam. O exit 0 era verdadeiro. Critério numérico não pega quem edita a régua.
  • A proteção que nunca roda. Código que parece proteger, atrás de uma condição que o chamador real nunca aciona. O teste chama a função direto e passa; produção nunca entra ali. Nenhuma leitura de código pegou — só apareceu rodando o artefato.

O que separou a entrega impecável da entrega com bug, medido em três sessões, não foi o modelo nem o isolamento:

Foi o briefing conter, ou não, o teste que falsificaria a entrega — com comando exato e saída exata esperada.

Suíte verde não é evidência. ✅ sem comando e saída colados não é verificação — e a saída colada tem que ser do objeto sobre o qual se está afirmando.

Travas mecânicas

Regra escrita não alcança o modo de falha em que o agente leu a regra e errou mesmo assim.

Enquanto o veredito de uma checagem for redigido pelo mesmo agente que ela deveria travar, ela não trava nada. Exit code não se argumenta.

As baterias dos gates rodam em Windows + Git Bash (ambiente do CI: runs-on: windows-latest); Linux e macOS não são medidos. Cada gate declara seu modelo de ameaça nos docblocks.

Hook (PreToolUse, exit 2)Barra
gate-worktree.cjsescrita de subagente fora de worktree linkado; git checkout/switch/reset com outra sessão no mesmo diretório
gate-staging-total.cjsgit add com caminho total (-A, ., *…) e git commit -a
gate-publicacao-destino.cjsescrita de dado sensível (JID, telefone, e-mail, credencial) em arquivo rastreado
gate-repo-alheio.cjsescrita cujo destino está dentro de outro repositório git
gate-fechar-issue.cjsgh issue close direto, e closes #N em PR sem comentário de evidência marcado. Corpo de heredoc é dado, não estrutura de comando: cat > x.md <<'EOF' com prosa ((x). Ele sobe., $(x), crase) passa; o corpo vira comando quando a linha do heredoc tem um interpretador (bash <<EOF, `cat <<EOF \bash, (sh) <<EOF, eval, source, pwsh…), e o texto dele é sempre varrido pelos padrões diretos (gh issue close 12` literal no corpo barra, como antes)
gate-mensagem-commit.cjsgit commit com assunto acima de 72 colunas ou terminando em ponto; sem corpo quando o stage passa de 3 arquivos ou 150 linhas; e mensagem que o hook não consegue ler (-F -, heredoc, git commit pelado — fechando merge, -F .git/MERGE_MSG)
gate-verificador-staged.cjsgit commit cujo conteúdo staged o verificador do repositório reprova — descoberta nesta ordem: chave "verificador-staged" em .rainforest/config.json; senão `scripts/check-personal-data.py.cjs.sh.js; senão scripts/conferir-publicacao.cjs. Materializa os blobs (git show :<caminho>`) numa pasta temporária e chama o verificador com esses caminhos; a saída dele vai no stderr. Repo sem verificador passa
gate-subagente-sem-gh.cjsdentro de subagente, gh que escreve no GitHub (issue close/comment/edit/create…, pr create/merge/comment…, release, gist, alias set, extension install, workflow run, run rerun, família que não é do gh — alias ou extensão —, api com método ≠ GET ou com campo) — no comando, atrás de wrapper, em bash -c (ilegível também nega), em heredoc e dentro do script executado, que é lido antes de rodar (bash/sh/source/python3/node/pwsh, ./x.sh, ./x.ps1, bash < x.sh, `cat x.sh \bash), e o código inline de node -e, python -c, pwsh -Command e cmd /c. Isenta só bateria testa-* rastreada pelo git; scripts/fechar-issue.cjs é negado pelo nome. A janela principal passa sempre. Toggle subagente-sem-gh`
gate-subagente-sem-instalar.cjsdentro de subagente, instalação de pacote (npm/pnpm/yarn/bun/pip/pipx/poetry/uv/cargo/go/gem/brew/apt/choco/scoop/winget, python -m pip, npx -y, Install-Module…) — também atrás de bash -c, sudo, env, FOO=1 e no PowerShell, decidido pelo subcomando (npm test, yarn build passam) —, e definir RAINFOREST_GATE_OFF — a chave de desligar gate não é do subagente. Não honra as próprias saídas de emergência. Toggle subagente-sem-instalar
portaria.cjsdespacho de subagente não declarado em .rainforest/agentes.json, ou sem isolation: "worktree" quando ele escreve; agente que escreve (escreve: true) fora dos estagios dele, sem leve na branch, em repo com fluxo
aviso-fluxo.cjs (PreToolUse em Edit/Write/MultiEdit)edição de arquivo de código fora de docs/ sem fluxo aberto nem leve na branch, nos trilhos rainforest e protheus. A mensagem dá as duas saídas: abrir o fluxo (/rainforest-mind:brainstorm, ou /protheus:trabalhar) ou node <plugin>/scripts/estado.cjs leve --motivo "...". Chave aviso-fluxo do config desliga

Um hook avisa sem barrar (exit 0 com additionalContext): idioma-session-start.cjs (SessionStart na compactação) reinjeta o idioma da chave idioma do config, que escorregava depois de compactar.

Fora da tabela porque o mecanismo é outro (Stop, exit 0 com {"decision":"block"}, e opt-in pela chave gate-review-codex): gate-review-codex.cjs barra encerrar o turno sem o revisor em Codex dizer ALLOW sobre a última resposta; Codex indisponível, resposta irreconhecível ou transcript que o hook não consegue ler bloqueiam com motivo (falha fechada). Só a chave desligada e o stop_hook_active do turno seguinte liberam sem perguntar. Codex sem cota bloqueia dizendo isso, com a hora de retorno (o despacho sai 75 e escreve codex sem cota: ...).

Fora da tabela, mesmo mecanismo (Stop, exit 2 com mensagem e ligado por padrão, desligável pela chave gate-turno-prometido em .rainforest/config.json): gate-turno-prometido.cjs barra o turno que prometeu despacho futuro ou em andamento (frases do design como "vou despachar", "despachando") e o turno não tem tool_use de Agent, Task, SendMessage nem Workflow; também barra turno que diz estar esperando máquina ("CI rodando", "aguardando build") e não tem Bash ou PowerShell com run_in_background === true, Monitor, nem ScheduleWakeup. Passado ("despachei") e negação ("não vou despachar") não disparam. Promessa com execução imediata passa; stop_hook_active === true libera. Subagente (payload com agent_id) não é conferido. Payload ou transcrição ilegível saem 0 com aviso no stderr.

Valem em qualquer repo git da máquina, porque o hábito é que é o problema, não o repositório. Cada uma tem bateria própria — 849 casos rodando o hook de verdade contra repos git montados na hora (soma re-medida em 2026-09-14, com os casos de heredoc e do staging citado: 201 + 106 + 24 + 27 + 151 + 25 + 307 + 8 — a portaria agora soma os dez testa-portaria-*.cjs, que o varrer-baterias.sh roda direto desde a 1.24, 92 + 14 + 24 + 10 + 28 + 8 + 16 + 87 + 5 + 23 (o portoes conta 5 porque imprime portões, não casos); re-verificar: a última linha da bateria hooks/testa-<hook>.sh de cada linha da tabela).

→ O incidente de origem de cada trava, as saídas de emergência e a tabela de scripts com exit code: docs/travas-mecanicas.md

ScriptValidação
scripts/estado.cjs iniciarRecusa (exit 2) abrir fluxo no checkout principal fora da branch padrão — a pasta do repositório fica na main, trabalho nasce em worktree (regra 11, Issue #195). A mensagem traz a receita (git worktree add …, git checkout main); exigir no mesmo estado só avisa. Clone dedicado a uma frente declara "principal-livre": true em .rainforest/config.json; em CI (CI/GITHUB_ACTIONS) não vale
scripts/recibo.cjsCongela identidade do entregável com sha256 + bytes; chamado pelo fechar quando plano declara entregaveis (opt-in, sem manifesto sai exit 0). Obriga nao_provado listado — recibo que alega provar tudo é suspeito. Re-executa portões com --reverificar se docs/rainforest/portoes/<slug>.md existe. Grava atomicamente em .rainforest/colheita/<slug>-recibo.json (fora do git). mostrar <slug> imprime; conferir <slug> recalcula hash e compara.
scripts/conferir-duplicacao.cjsDois arquivos byte a byte iguais fora de fixtures/, node_modules/, .git/ e .claude/worktrees/ → exit 2 com os dois caminhos na mesma linha; --funcoes inventaria nomes repetidos entre scripts/*.cjs (exit 0, é inventário). Chamado pelo conferir-publicacao.cjs --commit e pelo /saude
scripts/conferir-publicacao.cjs --commit <rev> ou a..bVarre o conteúdo commitado, não o disco: dado sensível que já saiu do arquivo mas ficou no histórico é achado; disco ≠ commit vira diverge-do-commit. Exit 2 achado, 69 quando o ambiente impede
scripts/conferir-glossario.cjsConfere o GLOSSARIO.md da raiz do repo e recusa verbete com campo obrigatório faltando (Definição, Onde mora, Cenário), termo duplicado ou linha injetada acima de 900 B: exit 1, cada defeito nomeado como GLOSSARIO.md:<linha>. --exigir torna a ausência do arquivo defeito; --caminhos confere caminho em crase. A CI roda com --exigir --caminhos (.github/workflows/baterias.yml)
scripts/estado.cjs marcar --json {"carimbos":…}Grava por tarefa o hash de base em que ela foi aceita (iteracao cresce, nada se apaga); proximo e ler avisam quando esse hash não é ancestral do HEAD
scripts/testa-teto-skills.shNenhum SKILL.md acima de 500 linhas ou 16.384 B — a falha diz o que mover para references/
scripts/testa-mapa-regras.shCada uma das 17 regras tem linha em ## Regra → trava de docs/travas-mecanicas.md (hook/script existente ou disciplina), e todo arquivo citado existe
scripts/conferir-fluxo.cjs mutacoes --slug <slug> [--plano <arquivo>]Roda a catraca de cada tarefa do plano que declara mutacao: (conferir-mutacao.cjs com os campos literais do bloco) e imprime uma linha por tarefa: vermelho, mutante sobreviveu ou pulada (<motivo>). Exit 1 se algum mutante sobreviveu, 0 se nenhum (ou tudo pulado), 2 se o plano não existe; --plano aponta o arquivo quando ele não está em docs/rainforest/planos/<slug>.md — é o que marcar verificar ok passa (Issue #192)
`scripts/eval-gatilho.sh travamutacao <skill>baseline`Eval de gatilho das skills (Issue #302), sob demanda e fora do CI — cada rodada é paga (~US$ 0,32 por caso). trava roda cada caso de evals/ 3 vezes com --ablation none (o disparo da skill conta no score) e aprova pela maioria; mutacao sabota a description da skill numa cópia e exige a trava vermelha; baseline roda with-without uma vez para o "acrescenta / peso morto". scripts/testa-eval-gatilho.sh prova o mecanismo com um claude falso, sem custo
scripts/testa-sandbox-com-trap.shGuarda estática das baterias testa-*.sh que criam sandbox: mais de um mktemp -d no arquivo exige o idioma SANDBOXES=() + função que registra cada caixa + um único trap … EXIT que varre o array; um só mktemp -d basta com trap … EXIT simples; sandbox sem trap nenhum reprova. A própria guarda e testa-dependencias-de-bateria.sh ficam fora da varredura real (seus fixtures citam mktemp -d como texto); --autoteste prova a guarda com fixtures sintéticos (Issue #216)
--confirmo em limpar-branches.cjs e limpar-worktrees.cjs --remover-sujoApagar branch e remover worktree sujo exigem a frase literal que o próprio script imprime (CONFIRMO apagar branches a,b), digitada por você e repassada verbatim — frase de outro alvo sai 2 e nada acontece
exit 69 nao-verificavel: em conferir-entrega, conferir-mutacao, conferir-fluxo, conferir-ponteAmbiente impediu a checagem (worktree sumiu, git fora do PATH, bateria que não executa): nem aprovação, nem reprovação, nem flaky — anuncia em uma linha e não redespacha (regra 14)

Comandos, skills e agentes

O fluxo

divergirAntes do brainstorm: seis frames isolados em paralelo mais um crítico cego. Devolve material, não decide
arqueologiaEstágio 0, opcional: mapeia a fatia de legado que a demanda toca
/brainstormEstágio 1: entrevista adversarial em árvore de decisão, grava o design
planoEstágio 2: tarefas tipadas, dependência declarada, critério falsificável
executarEstágio 3: despacha agentes, cada um em worktree isolado
revisarEstágio 4: contexto zerado, escopo fixado pelo diff — o relato não é fonte
verificarEstágio 5: roda o artefato real e cola a saída
fecharEstágio 6: commit, limpeza, PR como destino da branch
limparManutenção fora do fluxo: worktree e branch órfãos

Do dia a dia

/focoEstado da conversa; /foco <texto> declara novo foco
/ideia <texto>Avalia contra o foco: dentro → entra; fora → planta com contexto
/issueAbre Issue no repo em que você está, com o corpo escrito e a evidência colada
/feedbackRegistra o que a sessão ensinou sobre o método
/saudeSó o que os checadores oficiais não sabem
/setupMonta a pasta de dados, liga/desliga gates e fluxo
/semearPropõe o que criar neste repo a partir do que ele já tropeçou
glossarioPropõe, migra ou lista verbetes do GLOSSARIO.md do repo; grava só depois de aprovação (skill)
/vigiar <contato>Acompanha uma conversa de WhatsApp sem gastar token e acorda a sessão quando a pessoa escreve. Exige a integração integracao-whatsapp-mcp ligada e a bridge da conta de pé; o fluxo mora no repo whatsapp-mcp
/reguaRégua externa nomeada, builder contra crítico cego — para o que não tem teste. A Fase 0 destila a régua em 5-7 mecanismos conferíveis por olho em docs/rainforest/reguas/<slug>.md (o builder não os vê; o crítico sim) e faz o preflight de renderização, nomeando qual crítico ficaria cego. A régua é selada pelo commit que a adicionou: scripts/conferir-regua.cjs (validar antes de selar, conferir, mostrar) recusa régua alterada depois, e mostrar é o único caminho que a imprime. A partir da 2ª rodada, um segundo crítico cego compara o novo com o melhor guardado e decide keep/discard sozinho; as rodadas ficam num TSV versionado
/transferirLeva a sessão atual para outro lugar. Sem argumento: uma thread Codex retomável por codex resume <id>, exige transfer-codex ligado no /setup. /transferir claude: copia a conversa inteira para a outra conta do Claude Code nesta máquina (~/.claude ↔ ~/.claude-personal), sem chave, e imprime o cd + claude --resume <id> para rodar lá; `--para pessoal\trabalho força o destino, --forcar` sobrescreve uma cópia que já existe. A origem nunca é apagada
/ponteGera CLAUDE.md, AGENTS.md ou GEMINI.md (detalhe)
modo-devEscada YAGNI, causa raiz antes de remendo, rastreabilidade do diff
depurarConstrói o loop de feedback antes de qualquer hipótese
analisarAnálise em notebook: uma pergunta por vez, revisão crítica do achado

Agentes por função — a janela principal pensa; o agente executa.

executor · resolvedor-de-build · documentadorhaiku, tarefa mecânica
planejador · revisor · tester · depuradorsonnet, tarefa que exige julgamento
arqueologo · auditor-de-segurancasonnet, executam skill própria

O auditor-de-seguranca roda duas réguas OWASP (Top 10 2025 + API Security 2023) e uma Régua 3 de técnicas — referencias/reguas-tecnicas-ofensivas.md, 30 lentes de detecção defensiva derivadas do repositório SnailSploit/claude-red (MIT) e reescritas report-only — que afia as duas no nível de técnica de ataque.

Agentes em Claude ou Codex — cada agente tem um runtime: no manifesto (ausente = claude); a primeira linha do briefing pode ser Runtime: codex ou Runtime: claude para override (case-insensitive). Quando o usuário diz "faz no codex", "roda no codex" ou equivalente, o despacho põe Runtime: codex na primeira linha. Transporte via codex exec usa sandbox read-only ou workspace-write conforme escreve, modelo por codex-modelo-<haiku|sonnet|opus> no /setup (padrão do ~/.codex/config.toml). As skills review, adversarial-review e rescue do openai/codex-plugin-cc não ganharam comando aqui porque já existem como função neste plugin: revisor, segunda opinião

Source 9 files
hooks/mod.tsx 1230 lines
1// A barra de sessao e o pane /painel daqui sao adaptados do terminal-desk 0.2.1 (licenca
2// MIT, titular ClariSortAi); texto da licenca e origem em NOTICE, na raiz do plugin.
3// Entrada do mod: a abertura (register.ts, sem mudanca) mais a barra de sessao acima do
4// prompt (estado, tokens, custo, contexto, cache, subagentes, erros e o relogio) e o
5// comando /painel. A logica pura mora em ./painel-puro.mjs e ./relogio-puro.mjs; aqui so se
6// liga os eventos. O engine recusa `$` passado como argumento a qualquer funcao do arquivo,
7// entao `buscar` e `medir` recebem so valores e lambdas montadas no ponto de chamada de
8// cada hook. Toda leitura do mundo tem falha aberta: so a peca afetada some.
9import { atom, read, update } from 'claude-code'
10import type { Register } from 'claude-code'
11import type {
12  RainforestMindFaixaDados,
13  RainforestMindPainelAgente,
14  RainforestMindPainelDeixado,
15  RainforestMindPainelDeixadoOrigem,
16  RainforestMindPainelFatia,
17  RainforestMindPainelMapa,
18  RainforestMindPainelStats,
19  RainforestMindRelogioJornada,
20  RainforestMindRelogioSessoes,
21} from '../types'
22import { register as abertura } from './register.ts'
23import { CHECAR_MIN_FERRAMENTAS, deferimentos, lerResolvidosChecker, lerRespostaChecker, marcadoresEmArquivo, montarPromptChecker, perguntaDecisao, rascunhoFazAgora } from './deixado-puro.mjs'
24import { largura, cortar, semControle } from './faixa-puro.mjs'
25import { ESCRITORAS, escritaDe, mapaVazio, registrar, trocarCaminho } from './mapa-puro.mjs'
26import { cacheDe, compacto, dinheiro, fatias, figurasDaBarra, restante, ritmoPorMinuto, rotuloSubagente } from './painel-puro.mjs'
27import { avaliarRelogio, linhaRelogio, notaJornada } from './relogio-puro.mjs'
28
29type Fatia = RainforestMindPainelFatia
30
31const MIN = 60_000
32const HORA = 60 * MIN
33const MAX_AGENTES = 12
34
35const VAZIO: RainforestMindPainelStats = {
36  turnos: 0,
37  ferramentas: 0,
38  falhas: 0,
39  tokensNovos: 0,
40  tokensCacheLido: 0,
41  tokensCacheEscrito: 0,
42  tokensSaida: 0,
43  custoUsd: null,
44  ctxPct: null,
45  ctxTokens: null,
46  ctxJanela: null,
47  fatias: [],
48  carimbos: [],
49  agentes: [],
50  ultimaRequisicaoMs: 0,
51  modelo: null,
52  ttlMs: HORA,
53  medirDeNovo: false,
54}
55
56const faixaDados = atom({ plugin: 'rainforest-mind', key: 'faixaDados' } as const, null as RainforestMindFaixaDados | null)
57const painelStats = atom({ plugin: 'rainforest-mind', key: 'painelStats' } as const, VAZIO)
58const painelOculto = atom({ plugin: 'rainforest-mind', key: 'painelOculto' } as const, false)
59const painelMapa = atom({ plugin: 'rainforest-mind', key: 'painelMapa' } as const, mapaVazio() as RainforestMindPainelMapa)
60const relogioJornada = atom({ plugin: 'rainforest-mind', key: 'relogioJornada' } as const, null as RainforestMindRelogioJornada | null)
61const relogioSessoes = atom({ plugin: 'rainforest-mind', key: 'relogioSessoes' } as const, null as RainforestMindRelogioSessoes | null)
62const relogioNotaPendente = atom({ plugin: 'rainforest-mind', key: 'relogioNotaPendente' } as const, null as string | null)
63const relogioNotaEntregue = atom({ plugin: 'rainforest-mind', key: 'relogioNotaEntregue' } as const, null as string | null)
64
65// `PARADO` = nenhum checker em voo. O literal que desce o aviso mora so no `finally` da checagem,
66// a linha unica que a prova de mutacao troca.
67const PARADO = false
68const DEIXADO_VAZIO: RainforestMindPainelDeixado = { itens: [], proximo: 1, checando: PARADO, checar: true, pedido: '', ferramentas: [] }
69const painelDeixado = atom({ plugin: 'rainforest-mind', key: 'painelDeixado' } as const, DEIXADO_VAZIO)
70
71const deixadoInteiro = (d: RainforestMindPainelDeixado | null | undefined): RainforestMindPainelDeixado => ({ ...DEIXADO_VAZIO, ...(d ?? {}) })
72
73const inteiro = (s: RainforestMindPainelStats | null | undefined): RainforestMindPainelStats => ({ ...VAZIO, ...(s ?? {}) })
74
75type Io = {
76  rodar: (argv: string[], init: { cwd: string; env: Record<string, string>; timeoutMs: number }) => Promise<{ exitCode: number; stdout: string }>
77  cwd: () => Promise<string>
78  raiz: string
79}
80
81// Falha aberta: exit != 0, JSON invalido, timeout ou excecao devolvem null (apaga so os dados).
82async function buscar(io: Io): Promise<RainforestMindFaixaDados | null> {
83  try {
84    const cwd = await io.cwd()
85    const r = await io.rodar(
86      ['node', `${io.raiz}/scripts/faixa-dados.cjs`, '--cwd', cwd],
87      { cwd: io.raiz, env: { CLAUDE_PROJECT_DIR: cwd }, timeoutMs: 5000 },
88    )
89    return r.exitCode === 0 ? JSON.parse(r.stdout) : null
90  } catch {
91    return null
92  }
93}
94
95type IoRodar = { rodar: Io['rodar']; raiz: string }
96
97type IoAnotar = {
98  gravar: (f: (d: RainforestMindPainelDeixado) => RainforestMindPainelDeixado) => Promise<unknown>
99  avisar: (texto: string) => void
100}
101
102// Itens novos do "deixado para depois": o mesmo texto nao entra duas vezes, guarda os 40 mais
103// recentes e avisa com um toast so quando entrou algo. Recebe so valores e lambdas (o engine
104// recusa `$` como argumento).
105async function anotar(io: IoAnotar, achados: string[], origem: RainforestMindPainelDeixadoOrigem): Promise<void> {
106  if (achados.length === 0) return
107  let novos: string[] = []
108  await io.gravar(raw => {
109    const d = deixadoInteiro(raw)
110    novos = [...new Set(achados)].filter(texto => !d.itens.some(i => i.texto === texto))
111    if (novos.length === 0) return d
112    return {
113      ...d,
114      proximo: d.proximo + novos.length,
115      itens: [...d.itens, ...novos.map((texto, i) => ({ id: d.proximo + i, texto, origem, estado: 'aberto' as const }))].slice(-40),
116    }
117  })
118  if (novos.length > 0) io.avisar(`Deixado para depois: ${(novos[0] ?? '').slice(0, 80)}`)
119}
120
121// Falha aberta: exit != 0 (jornada.cjs exit 2 = sem linha), JSON invalido, timeout ou excecao
122// devolvem null (apaga so a leitura).
123async function rodarJson(io: IoRodar, argv: string[], timeoutMs: number, env: Record<string, string>): Promise<unknown> {
124  try {
125    const r = await io.rodar(argv, { cwd: io.raiz, env, timeoutMs })
126    return r.exitCode === 0 ? JSON.parse(r.stdout) : null
127  } catch {
128    return null
129  }
130}
131
132function jornadaDe(bruto: any): RainforestMindRelogioJornada | null {
133  const ultimo_ms = bruto ? Date.parse(bruto.ultimo) : NaN
134  if (!bruto || typeof bruto.efetiva_min !== 'number' || !Number.isFinite(ultimo_ms)) return null
135  return { efetiva_min: bruto.efetiva_min, ultimo_ms }
136}
137
138function sessoesDe(bruto: any): RainforestMindRelogioSessoes | null {
139  if (!bruto || typeof bruto.ociosidade_min !== 'number' || !Array.isArray(bruto.janelas)) return null
140  return { ociosidade_min: bruto.ociosidade_min, janelas: bruto.janelas }
141}
142
143// Linha do relogio para a barra; qualquer falha apaga so o relogio.
144function linhaDoRelogio(jornada: RainforestMindRelogioJornada | null, sessoes: RainforestMindRelogioSessoes | null, agora: number): string | null {
145  try {
146    return linhaRelogio(avaliarRelogio({ jornada, sessoes, agora })) as string | null
147  } catch {
148    return null
149  }
150}
151
152type IoMedir = {
153  usage: () => Promise<any>
154  gravar: (f: (s: RainforestMindPainelStats) => RainforestMindPainelStats) => Promise<unknown>
155  log: (texto: string) => void
156}
157
158// Contexto e custo pela estimativa local (`breakdown: 'summary'`; `full` chama a API de
159// contagem a cada turno). Falha aberta: o contexto some da barra, o resto fica.
160async function medir(io: IoMedir): Promise<void> {
161  let uso: any
162  try {
163    uso = await io.usage()
164  } catch (err) {
165    try {
166      io.log(`painel: contexto: ${String(err)}`)
167    } catch {
168      // sem log, sem problema
169    }
170    try {
171      await io.gravar(raw => ({ ...inteiro(raw), ctxPct: null, ctxTokens: null, ctxJanela: null, fatias: [], medirDeNovo: false }))
172    } catch {
173      // a medicao nunca quebra nada
174    }
175    return
176  }
177  try {
178    const contexto = uso?.context ?? {}
179    const detalhe = contexto.breakdown
180    const fatias = (Array.isArray(detalhe?.categories) ? detalhe.categories : [])
181      .filter((c: any) => c && typeof c.tokens === 'number' && c.tokens > 0)
182      .map((c: any) => ({ nome: String(c.name), tokens: c.tokens as number, tipo: String(c.kind) }))
183    const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
184    await io.gravar(raw => {
185      const s = inteiro(raw)
186      return {
187        ...s,
188        custoUsd: num(uso?.cost?.usd) ?? s.custoUsd,
189        ctxPct: num(detalhe?.percentage) ?? num(contexto.percent),
190        ctxTokens: num(contexto.tokens) ?? num(detalhe?.totalTokens),
191        ctxJanela: num(detalhe?.rawMaxTokens) ?? num(contexto.window),
192        fatias,
193        medirDeNovo: false,
194      }
195    })
196  } catch {
197    // a medicao nunca quebra nada
198  }
199}
200
201// Subagentes: a linha de um agente nunca visto comeca em branco; acima do teto saem os
202// mais antigos ja encerrados (um em andamento nunca sai).
203function comAgente(agentes: RainforestMindPainelAgente[], id: string, at: number, mudar: (a: RainforestMindPainelAgente) => RainforestMindPainelAgente): RainforestMindPainelAgente[] {
204  const conhecido: RainforestMindPainelAgente = agentes.find(a => a.id === id) ?? {
205    id,
206    rotulo: `agente ${id.slice(0, 6)}`,
207    tipo: 'subagente',
208    modelo: null,
209    iniciadoEm: at,
210    fimEm: null,
211    vistoEm: at,
212    criadoAqui: false,
213    ferramentas: 0,
214    tokensLidos: 0,
215    tokensSaida: 0,
216    falhou: false,
217  }
218  const linhas = [...agentes.filter(a => a.id !== id), { ...mudar(conhecido), vistoEm: at }]
219  const sobra = linhas.filter(a => a.fimEm !== null).slice(0, Math.max(0, linhas.length - MAX_AGENTES))
220  return linhas.filter(a => !sobra.includes(a))
221}
222
223// Um agente que ninguem viu comecar nesta carga do mod pode nunca avisar que acabou: o
224// silencio de 2 min o encerra.
225const emAndamento = (a: RainforestMindPainelAgente, agora: number): boolean =>
226  a.fimEm === null && (a.criadoAqui || agora - a.vistoEm < 2 * MIN)
227
228const mmss = (ms: number): string => {
229  const s = Math.max(0, Math.floor(ms / 1000))
230  return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
231}
232
233const COR = { ambar: '#ffb000', verde: '#00d67a', vermelho: '#ff4d4d', ciano: '#4dd2ff', cinza: '#808080' }
234const CORES_FATIA = ['#4dd2ff', '#00d67a', '#ffb000', '#c792ea', '#ff7eb6', '#7aa2f7', '#e0af68']
235const PANE = 'painel'
236const FATIAS_NA_LEGENDA = 7
237const AGENTES_NO_PANE = 6
238
239// O slug do fluxo comeca pela data de criacao; no pane so o nome conta.
240const slugSemData = (slug: string): string => slug.replace(/^\d{4}-\d{2}-\d{2}-/, '')
241
242const AJUDA = 'Subcomandos: esconder, mostrar, cache 5m|1h, checar ligar|desligar, limpar, erros [horas]. Sem argumento abre o painel.'
243// Ate tantas escritas esperam a vez do script do desvio; as demais entram no mapa sem veredito.
244const FILA_DESVIO_MAX = 50
245const VEREDITOS_DEFINITIVOS = new Set(['dentro', 'fora', 'isento'])
246
247export const register: Register = (on, options) => {
248  abertura(on, options)
249
250  // Desvio do plano: no maximo um spawn do script por vez. Escritas que chegam durante ele
251  // esperam em `filaDesvio`; `desvioPendente` (na fila ou rodando) e `desvioConsultado` (ja
252  // tem veredito) garantem que cada caminho roda uma vez so. Zerados a cada sessao.
253  const filaDesvio: { escrita: string; rodar: () => Promise<boolean> }[] = []
254  const desvioPendente = new Set<string>()
255  const desvioConsultado = new Set<string>()
256  // Relativo que o script ja devolveu para cada caminho absoluto: a falha aberta e a fila cheia
257  // registram com ele, para o mesmo arquivo nao aparecer duas vezes no mapa.
258  const relConhecido = new Map<string, string>()
259  let desvioRodando = false
260  const drenarDesvios = async (): Promise<void> => {
261    if (desvioRodando) return
262    desvioRodando = true
263    try {
264      for (let item = filaDesvio.shift(); item !== undefined; item = filaDesvio.shift()) {
265        try {
266          if (await item.rodar()) desvioConsultado.add(item.escrita)
267        } catch {
268          // o desvio nunca quebra a ferramenta
269        }
270        desvioPendente.delete(item.escrita)
271      }
272    } finally {
273      desvioRodando = false
274    }
275  }
276
277  // Timers do relogio e do tique da barra: ficam no escopo do register para o session.end e
278  // o proximo session.start cancelarem os da sessao anterior.
279  let timers: { cancel: () => void }[] = []
280  // Id da sessao que armou os timers: so o session.end dela cancela o relogio.
281  let armadoPor: string | null = null
282  // O tique da barra (1 s) so existe depois de alguma atividade (ferramenta, passo, subagente,
283  // turno) ou de um compact que pediu medicao nova, e se desarma quando nada mais conta: parado,
284  // nao ha timer nenhum.
285  let ativo = false
286  let medirPendente = false
287  let tiqueTimer: { cancel: () => void } | null = null
288  let armarTique: (() => void) | null = null
289  const acordar = () => {
290    ativo = true
291    try {
292      if (armarTique !== null) armarTique()
293    } catch {
294      // sem tique, a barra so se redesenha quando o estado muda
295    }
296  }
297  const cancelarRelogio = () => {
298    armadoPor = null
299    armarTique = null
300    if (tiqueTimer !== null) {
301      try {
302        tiqueTimer.cancel()
303      } catch {
304        // timer ja encerrado
305      }
306      tiqueTimer = null
307    }
308    ativo = false
309    const velhos = timers
310    timers = []
311    for (const t of velhos) {
312      try {
313        t.cancel()
314      } catch {
315        // timer ja encerrado
316      }
317    }
318  }
319
320  on('session.start', async ($, e, next) => {
321    try {
322      await $.command.register({
323        name: 'painel',
324        description: 'Painel da sessao: /painel abre o pane; esconder, mostrar, cache 5m|1h',
325      })
326    } catch {
327      // sem comando, a barra segue
328    }
329    // claude -p, SDK e subagente nao desenham a barra: sem timer, sem ler os fluxos do painel,
330    // e sem cancelar o relogio da sessao interativa que compartilha esta instancia do mod.
331    if (!e.isInteractive) return next(e)
332    // Depois do guard: sessao nao interativa (claude -p, SDK) que compartilha a instancia nao
333    // zera o cache da interativa.
334    desvioConsultado.clear()
335    relConhecido.clear()
336    try {
337      const dados = await buscar({
338        rodar: (argv, init) => $.process.run(argv, init),
339        cwd: () => $.session.cwd(),
340        raiz: $.plugin.root,
341      })
342      await update($, faixaDados, () => dados)
343    } catch {
344      // a barra nunca quebra a abertura da sessao
345    }
346    cancelarRelogio()
347    try {
348      const id = await $.session.id()
349      armadoPor = id
350      const cwd = await $.session.cwd()
351      const raiz = $.plugin.root
352      const io = { rodar: (argv: string[], init: { cwd: string; env: Record<string, string>; timeoutMs: number }) => $.process.run(argv, init), raiz }
353      let sessoesEmCurso = false
354      let jornadaEmCurso = false
355
356      // Arma a nota do dia quando a jornada acende e o dia ainda nao foi entregue.
357      const reavaliar = async () => {
358        try {
359          const agora = await $.clock.now()
360          const rel = avaliarRelogio({ jornada: await read($, relogioJornada), sessoes: await read($, relogioSessoes), agora })
361          if (rel.jornada) {
362            const dia = rel.jornada.dia
363            const entregue = await read($, relogioNotaEntregue)
364            const pendente = await read($, relogioNotaPendente)
365            if (entregue !== dia && pendente !== dia) await update($, relogioNotaPendente, () => dia)
366          }
367        } catch {
368          // o relogio nunca quebra nada
369        }
370        try {
371          $.ui.invalidate('ui.render')
372        } catch {
373          // sem tela, sem redesenho
374        }
375      }
376
377      const lerSessoes = async () => {
378        if (sessoesEmCurso) return
379        sessoesEmCurso = true
380        try {
381          // Depois de /clear o processo segue com outro id: le-o de novo uma vez.
382          if (armadoPor === null) armadoPor = await $.session.id()
383          const bruto = await rodarJson(io, ['node', `${raiz}/scripts/relogio-sessoes.cjs`, '--cwd', cwd, '--sessao', armadoPor], 5000, { CLAUDE_PROJECT_DIR: cwd })
384          const dados = sessoesDe(bruto)
385          await update($, relogioSessoes, () => dados)
386          await reavaliar()
387        } catch {
388          // falha apaga so a figura do relogio
389        } finally {
390          sessoesEmCurso = false
391        }
392      }
393
394      const lerJornada = async () => {
395        if (jornadaEmCurso) return
396        jornadaEmCurso = true
397        try {
398          const bruto = await rodarJson(io, ['node', `${raiz}/scripts/jornada.cjs`, '--json'], 60000, {})
399          const dados = jornadaDe(bruto)
400          await update($, relogioJornada, () => dados)
401          await reavaliar()
402        } catch {
403          // falha apaga so a figura do relogio
404        } finally {
405          jornadaEmCurso = false
406        }
407      }
408
409      // A contagem do cache e o ritmo andam sozinhos: o tique redesenha a barra a cada segundo
410      // enquanto algo conta (cache quente, ferramenta no ultimo minuto, subagente) e refaz a
411      // medicao que um compact deixou pendente. Nunca roda processo.
412      const tique = async () => {
413        if (!ativo && !medirPendente) return
414        try {
415          if (medirPendente) {
416            medirPendente = false
417            await medir({
418              usage: () => $.session.usage({ breakdown: 'summary' }),
419              gravar: f => update($, painelStats, f),
420              log: m => $.ui.log(m, { to: 'debug' }),
421            })
422          }
423          const s = inteiro(await read($, painelStats))
424          const agora = await $.clock.now()
425          const contando =
426            ritmoPorMinuto(s.carimbos, agora) > 0 ||
427            s.agentes.some(a => emAndamento(a, agora)) ||
428            cacheDe({ modelo: s.modelo, ctxTokens: s.ctxTokens, ultimaRequisicaoMs: s.ultimaRequisicaoMs, agora, ttlMs: s.ttlMs }).quente
429          if (contando) $.ui.invalidate('ui.render')
430          else {
431            ativo = false
432            if (tiqueTimer !== null) tiqueTimer.cancel()
433            tiqueTimer = null
434          }
435        } catch {
436          // o tique nunca quebra nada
437        }
438      }
439
440      timers.push($.clock.after(1000, lerSessoes))
441      timers.push($.clock.after(2000, lerJornada))
442      timers.push($.clock.every(60000, lerSessoes))
443      timers.push($.clock.every(300000, lerJornada))
444      armarTique = () => {
445        if (tiqueTimer === null) tiqueTimer = $.clock.every(1000, tique)
446      }
447    } catch {
448      // o relogio nunca quebra a abertura da sessao
449    }
450    return next(e)
451  })
452
453  // O matcher (qualquer motivo) evita colidir com o session.end sem matcher da abertura.
454  on('session.end', { reason: /.*/ }, async (_$, e, next) => {
455    if (armadoPor !== null && e.sessionId === armadoPor) {
456      // /clear nao tem session.start depois: o relogio segue e o id novo e lido no proximo tick.
457      if (e.reason === 'clear') armadoPor = null
458      else cancelarRelogio()
459    }
460    return next(e)
461  })
462
463  on('command.run', { command: 'painel' }, async ($, e) => {
464    const palavra = `${e.args ?? ''}`.trim().toLowerCase()
465    if (palavra === 'esconder' || palavra === 'mostrar') {
466      await update($, painelOculto, () => palavra === 'esconder')
467      return { text: palavra === 'esconder' ? 'Barra escondida.' : 'Barra de volta.' }
468    }
469    if (palavra === 'cache 5m' || palavra === 'cache 1h') {
470      await update($, painelStats, s => ({ ...inteiro(s), ttlMs: palavra === 'cache 5m' ? 5 * MIN : HORA }))
471      return { text: `Vida do cache em ${palavra.slice(6)}.` }
472    }
473    if (palavra === 'checar ligar' || palavra === 'checar desligar') {
474      await update($, painelDeixado, d => ({ ...deixadoInteiro(d), checar: palavra === 'checar ligar' }))
475      return { text: palavra === 'checar ligar' ? 'Checagem do segundo modelo ligada.' : 'Checagem do segundo modelo desligada.' }
476    }
477    if (palavra === 'erros' || /^erros \d+$/.test(palavra)) {
478      const horas = palavra === 'erros' ? '24' : palavra.slice(6)
479      try {
480        const cwd = await $.session.cwd()
481        const r = await $.process.run(['node', `${$.plugin.root}/scripts/erros.cjs`, 'listar', '--horas', horas], {
482          cwd: $.plugin.root,
483          env: { CLAUDE_PROJECT_DIR: cwd },
484          timeoutMs: 10000,
485        })
486        return { text: r.exitCode === 0 ? r.stdout.trimEnd() : `Log de erros indisponível (exit ${r.exitCode}).` }
487      } catch {
488        return { text: 'Log de erros indisponível.' }
489      }
490    }
491    if (palavra === 'limpar') {
492      await update($, painelDeixado, d => ({ ...deixadoInteiro(d), itens: [] }))
493      return { text: 'Deixado para depois zerado.' }
494    }
495    if (palavra !== '') return { text: AJUDA }
496    // Sem argumento: abre o pane, mede o contexto e atualiza os fluxos. Cada peca que falha
497    // some sozinha; o comando sempre devolve o texto.
498    let aberto = false
499    try {
500      aberto = (await $.ui.open({ id: PANE, title: 'Esta sessão' })).isPlaced === true
501    } catch {
502      // sem pane, o texto do comando basta
503    }
504    try {
505      await medir({
506        usage: () => $.session.usage({ breakdown: 'summary' }),
507        gravar: f => update($, painelStats, f),
508        log: m => $.ui.log(m, { to: 'debug' }),
509      })
510      const dados = await buscar({
511        rodar: (argv, init) => $.process.run(argv, init),
512        cwd: () => $.session.cwd(),
513        raiz: $.plugin.root,
514      })
515      await update($, faixaDados, () => dados)
516    } catch {
517      // a medicao nunca quebra o comando
518    }
519    if (aberto) return { text: 'Painel aberto.' }
520    const s = inteiro(await read($, painelStats))
521    return { text: `Painel indisponível aqui. Contexto ${s.ctxPct === null ? '--' : Math.round(s.ctxPct) + '%'}, custo ${dinheiro(s.custoUsd)}.` }
522  })
523
524  on('agent.spawn', async ($, e, next) => {
525    const criado = await next(e)
526    acordar()
527    try {
528      if (criado.agentId !== undefined) {
529        const id = criado.agentId
530        const at = await $.clock.now()
531        await update($, painelStats, raw => {
532          const s = inteiro(raw)
533          return {
534            ...s,
535            agentes: comAgente(s.agentes, id, at, a => ({
536              ...a,
537              rotulo: rotuloSubagente(e.description, 40),
538              tipo: String(e.subagentType),
539              modelo: criado.model,
540              criadoAqui: true,
541              fimEm: null,
542            })),
543          }
544        })
545      }
546    } catch {
547      // o contador nunca quebra o spawn
548    }
549    try {
550      await update($, painelMapa, raw => registrar(raw, { agente: true, description: e.description }).mapa as RainforestMindPainelMapa)
551    } catch {
552      // o mapa nunca quebra o spawn
553    }
554    return criado
555  })
556
557  // Nota da regra 8: uma vez por dia, so em prompt digitado no composer (nao em loop/schedule/system).
558  // Vai em `context`: o modelo le, o usuario nao ve, e o texto do prompt segue intacto. O evento e
559  // o `prompt.submit` porque o gancho classico de envio (UserPromptSubmit) nao roda, em mod, no
560  // composer do REPL.
561  on('prompt.submit', async ($, e, next) => {
562    // O pedido da pessoa fica guardado para o checker do fim do turno (so o que ela mesma mandou).
563    try {
564      const tipo: string | undefined = e.origin?.kind
565      if (tipo === undefined || tipo === 'composer' || tipo === 'bridge' || tipo === 'sdk') {
566        const pedido = String(e.text ?? '').slice(0, 4000)
567        await update($, painelDeixado, d => ({ ...deixadoInteiro(d), pedido }))
568      }
569    } catch {
570      // o pedido guardado nunca quebra o envio
571    }
572    let nota: string | null = null
573    try {
574      if (e.origin === undefined || e.origin.kind === 'composer') {
575        const pendente = await read($, relogioNotaPendente)
576        if (pendente) {
577          const agora = await $.clock.now()
578          const rel = avaliarRelogio({ jornada: await read($, relogioJornada), sessoes: await read($, relogioSessoes), agora })
579          const dia = rel.jornada ? rel.jornada.dia : null
580          const texto = notaJornada(rel) as string | null
581          if (dia && texto && pendente === dia) {
582            await update($, relogioNotaEntregue, () => dia)
583            await update($, relogioNotaPendente, () => null)
584            nota = texto
585          }
586        }
587      }
588    } catch {
589      // a nota nunca quebra o envio
590    }
591    if (nota === null) return next(e)
592    return next({ ...e, context: [...(e.context ?? []), nota] })
593  })
594
595  // Cada requisicao do laco. A vida do cache corre do comeco da ultima requisicao do laco
596  // principal; uma requisicao de subagente quer dizer que ele esta trabalhando.
597  on('turn.step', async function* ($, e, next) {
598    acordar()
599    try {
600      const at = await $.clock.now()
601      const agentId = e.agentId
602      await update($, painelStats, raw => {
603        const s = inteiro(raw)
604        return agentId === undefined
605          ? { ...s, ultimaRequisicaoMs: at }
606          : { ...s, agentes: comAgente(s.agentes, agentId, at, a => ({ ...a, fimEm: null })) }
607      })
608    } catch {
609      // o contador nunca quebra o passo
610    }
611    return yield* next(e)
612  })
613
614  on('tool.call', async ($, e, next) => {
615    const ran = await next(e)
616    acordar()
617    try {
618      const at = await $.clock.now()
619      const agentId = e.agentId
620      const falhou = ran.deny !== undefined || ran.isError === true
621      await update($, painelStats, raw => {
622        const s = inteiro(raw)
623        return {
624          ...s,
625          ferramentas: s.ferramentas + 1,
626          falhas: s.falhas + (falhou ? 1 : 0),
627          carimbos: [...s.carimbos, at].filter(t => at - t <= MIN),
628          agentes: agentId === undefined ? s.agentes : comAgente(s.agentes, agentId, at, a => ({ ...a, fimEm: null, ferramentas: a.ferramentas + 1 })),
629        }
630      })
631    } catch {
632      // o contador nunca quebra a ferramenta
633    }
634    // Cada falha vira uma linha em <raiz>/erros.jsonl na hora (scripts/erros.cjs), para triar
635    // depois com /painel erros. Na hora e nao no SessionEnd: janela fechada no X nao dispara
636    // SessionEnd. So roda em falha (custa um node, ~100 ms) e nunca quebra a ferramenta. O corte
637    // fino e o mascaramento de segredo sao do script: cortar aqui deixaria meio token sem mascara.
638    if (ran.deny !== undefined || ran.isError === true) {
639      const entrada = e as unknown as Record<string, unknown>
640      const comando = entrada.command ?? entrada.file_path ?? entrada.notebook_path ?? entrada.pattern ?? entrada.url ?? entrada.description ?? ''
641      try {
642        const cwd = await $.session.cwd()
643        const falha = {
644          sessao: await $.session.id(),
645          cwd,
646          ferramenta: String(e.tool),
647          tipo: ran.deny !== undefined ? 'bloqueio' : 'erro',
648          comando: semControle(String(comando)).slice(0, 4000),
649          mensagem: semControle(String(ran.deny ?? ran.text ?? '')).slice(0, 4000),
650        }
651        await $.process.run(['node', `${$.plugin.root}/scripts/erros.cjs`, 'gravar'], {
652          cwd: $.plugin.root,
653          env: { CLAUDE_PROJECT_DIR: cwd },
654          stdin: JSON.stringify(falha),
655          timeoutMs: 5000,
656        })
657      } catch {
658        // o log de erros nunca quebra a ferramenta
659      }
660    }
661    try {
662      const falhou = ran.deny !== undefined || ran.isError === true
663      if (e.agentId === undefined) {
664        await update($, painelDeixado, d => {
665          const x = deixadoInteiro(d)
666          return { ...x, ferramentas: [...x.ferramentas, { tool: String(e.tool), deny: ran.deny !== undefined, isError: ran.isError === true }] }
667        })
668      }
669      if (!falhou && ESCRITORAS.has(String(e.tool))) {
670        const entrada = e as unknown as Record<string, unknown>
671        const escrito = String(entrada.new_string ?? entrada.content ?? entrada.new_source ?? '')
672        const linha = (marcadoresEmArquivo(escrito) as string[])[0]
673        if (linha !== undefined) {
674          // O nome do arquivo e a linha vem do modelo: nenhum controle chega ao Text nem ao toast.
675          const arquivo = semControle(String(entrada.file_path ?? entrada.notebook_path ?? 'arquivo')).split(/[\\/]/).pop() ?? 'arquivo'
676          await anotar(
677            { gravar: f => update($, painelDeixado, f), avisar: t => $.ui.toast(t) },
678            [`Escreveu "${semControle(linha).slice(0, 90)}" em ${arquivo}`],
679            'em arquivo',
680          )
681        }
682      }
683    } catch {
684      // o deixado nunca quebra a ferramenta
685    }
686    try {
687      if (ran.deny === undefined && ran.isError !== true) {
688        const escrita = escritaDe(e) as string | null
689        if (escrita === null) {
690          await update($, painelMapa, raw => registrar(raw, e).mapa as RainforestMindPainelMapa)
691        } else {
692          // Escrita: o script do desvio roda em segundo plano (3 a 5 s medidos) e o resultado
693          // da ferramenta nao espera por ele. Um spawn por vez: as escritas que chegam durante
694          // ele esperam na fila, e cada caminho roda uma vez so (veredito ou espera ja valem
695          // por ele). O arquivo entra no mapa quando a resposta chega, para o `novo` do desvio
696          // valer uma vez por arquivo (D15). Nada daqui vai ao modelo.
697          if (desvioConsultado.has(escrita) || desvioPendente.has(escrita)) {
698            // ja tem veredito, ou ja espera a vez
699          } else if (filaDesvio.length >= FILA_DESVIO_MAX) {
700            // Fila cheia: lista sem veredito. O caminho nao fica marcado, entao a proxima escrita
701            // nele tenta de novo; arquivo que nao for reescrito nao tem o desvio checado.
702            const rel = relConhecido.get(escrita)
703            await update($, painelMapa, raw => registrar(raw, rel === undefined ? e : { tool: e.tool, file_path: rel, notebook_path: rel }).mapa as RainforestMindPainelMapa)
704          } else {
705            const raiz = $.plugin.root
706            const log = (err: unknown) => {
707              try {
708                $.ui.log(`painel: desvio: ${String(err)}`, { to: 'debug' })
709              } catch {
710                // sem log, sem problema
711              }
712            }
713            // Devolve true quando o script respondeu um veredito; falha aberta, nunca lanca.
714            const rodar = async (): Promise<boolean> => {
715              let desvio: { veredito?: string; rel?: string | null } | null = null
716              try {
717                const cwd = await $.session.cwd()
718                const r = await $.process.run(
719                  ['node', `${raiz}/scripts/desvio-do-plano.cjs`, '--cwd', cwd, '--arquivo', escrita],
720                  // Roda em segundo plano depois da escrita: sob carga passa de 5 s, e o teto folga.
721                  { cwd: raiz, env: { CLAUDE_PROJECT_DIR: cwd }, timeoutMs: 10000 },
722                )
723                const lido = r.exitCode === 0 ? JSON.parse(r.stdout) : null
724                desvio = lido !== null && typeof lido === 'object' && typeof lido.veredito === 'string' ? lido : null
725              } catch (err) {
726                log(err)
727              }
728              try {
729                if (typeof desvio?.rel === 'string' && desvio.rel !== '') relConhecido.set(escrita, desvio.rel)
730                const caminho: string = relConhecido.get(escrita) ?? escrita
731                const escreveu = { tool: e.tool, file_path: caminho, notebook_path: caminho }
732                let avisar = false
733                await update($, painelMapa, cru => {
734                  const raw = trocarCaminho(cru, escrita, caminho)
735                  if (desvio === null) return registrar(raw, escreveu).mapa as RainforestMindPainelMapa
736                  const marcado = registrar(raw, { desvio: true, caminho })
737                  if (desvio.veredito === 'fora' && marcado.novo) {
738                    avisar = true
739                  }
740                  return (desvio.veredito === 'fora' ? marcado.mapa : registrar(raw, escreveu).mapa) as RainforestMindPainelMapa
741                })
742                if (avisar) $.ui.toast(`Fora dos arquivos do plano: ${caminho}`)
743              } catch (err) {
744                log(err)
745              }
746              // So veredito definitivo vai ao cache: sem-fluxo, sem-plano, sem-arquivos e fora-da-raiz
747              // mudam na mesma sessao (o plano nasce depois da escrita), e a reescrita checa de novo.
748              return desvio !== null && VEREDITOS_DEFINITIVOS.has(String(desvio.veredito))
749            }
750            desvioPendente.add(escrita)
751            filaDesvio.push({ escrita, rodar })
752            void drenarDesvios()
753          }
754        }
755      }
756    } catch {
757      // o mapa nunca quebra a ferramenta
758    }
759    return ran
760  })
761
762  on('turn.complete', async ($, e, next) => {
763    const agentId = e.agentId
764    acordar()
765    const log = (m: string) => {
766      try {
767        $.ui.log(m, { to: 'debug' })
768      } catch {
769        // sem log, sem problema
770      }
771    }
772    // As ferramentas e o pedido do turno saem do estado e a lista zera ANTES de qualquer outra
773    // espera: o turno seguinte ja pode estar chamando ferramentas enquanto este fecha as medidas.
774    const turno: { d: RainforestMindPainelDeixado | null } = { d: null }
775    if (agentId === undefined) {
776      try {
777        await update($, painelDeixado, x => {
778          const d = deixadoInteiro(x)
779          turno.d = d
780          return { ...d, ferramentas: [] }
781        })
782      } catch {
783        // o deixado nunca quebra o turno
784      }
785    }
786    try {
787      const at = await $.clock.now()
788      const saida = e.usage?.output_tokens ?? 0
789      // `input_tokens` e so a parte sem cache; um prompt em cache reporta quase tudo nos dois
790      // campos de cache.
791      const novos = e.usage?.input_tokens ?? 0
792      const lidos = e.usage?.cache_read_input_tokens ?? 0
793      const escritos = e.usage?.cache_creation_input_tokens ?? 0
794      await update($, painelStats, raw => {
795        const s = inteiro(raw)
796        return {
797          ...s,
798          // So onde nenhuma requisicao do laco principal foi vista comecar.
799          ultimaRequisicaoMs: agentId === undefined && s.ultimaRequisicaoMs === 0 ? at : s.ultimaRequisicaoMs,
800          modelo: agentId === undefined ? (e.usage?.model ?? s.modelo) : s.modelo,
801          turnos: s.turnos + (agentId === undefined ? 1 : 0),
802          tokensNovos: s.tokensNovos + novos,
803          tokensCacheLido: s.tokensCacheLido + lidos,
804          tokensCacheEscrito: s.tokensCacheEscrito + escritos,
805          tokensSaida: s.tokensSaida + saida,
806          agentes: agentId === undefined
807            ? s.agentes
808            : comAgente(s.agentes, agentId, at, a => ({
809                ...a,
810                fimEm: at,
811                modelo: e.usage?.model ?? a.modelo,
812                tokensLidos: a.tokensLidos + novos + lidos + escritos,
813                tokensSaida: a.tokensSaida + saida,
814                falhou: e.reason === 'error' || e.reason === 'aborted',
815              })),
816        }
817      })
818      if (agentId === undefined) {
819        // Sem await: medir (session.usage) e buscar (scripts) levam segundos e o turno nao
820        // espera por eles. Falha aberta: cada um apaga so a propria leitura.
821        medir({
822          usage: () => $.session.usage({ breakdown: 'summary' }),
823          gravar: f => update($, painelStats, f),
824          log: m => $.ui.log(m, { to: 'debug' }),
825        }).catch((err: unknown) => log(`painel: medir: ${String(err)}`))
826        buscar({
827          rodar: (argv, init) => $.process.run(argv, init),
828          cwd: () => $.session.cwd(),
829          raiz: $.plugin.root,
830        })
831          .then(dados => update($, faixaDados, () => dados))
832          .catch((err: unknown) => log(`painel: buscar: ${String(err)}`))
833      }
834    } catch {
835      // a barra nunca quebra o turno
836    }
837    if (agentId === undefined) {
838      try {
839        const d = turno.d
840        if (d !== null && e.reason === 'answer') {
841          const relato = String(e.answer ?? '')
842          const io = { gravar: (f: (x: RainforestMindPainelDeixado) => RainforestMindPainelDeixado) => update($, painelDeixado, f), avisar: (t: string) => $.ui.toast(t) }
843          await anotar(io, deferimentos(relato) as string[], 'Claude disse')
844          const ligado = d.checar
845          const ferramentas = d.ferramentas
846          const pedido = d.pedido
847          // Duas metades: achar pendencia nova pede turno de 5+ ferramentas e relato sem decisao
848          // `Q<n>` para a pessoa (a Q espera a palavra dela); fechar item resolvido roda em todo
849          // turno com item aberto, porque a fala da pessoa no pedido tambem e evidencia.
850          const abertos = d.itens.filter(i => i.estado === 'aberto').map(i => ({ id: i.id, texto: i.texto }))
851          const acharNovos = ferramentas.length >= CHECAR_MIN_FERRAMENTAS && !perguntaDecisao(relato)
852          const deveChecar = ligado && pedido !== '' && (acharNovos || abertos.length > 0);
853          if (deveChecar) {
854            // Sem await: o turno acaba agora e o achado chega quando chegar.
855            await update($, painelDeixado, x => ({ ...deixadoInteiro(x), checando: true }))
856            const checagem = async () => {
857              try {
858                const r = await $.model.complete({
859                  model: 'haiku',
860                  prompt: montarPromptChecker({ pedido, relato, ferramentas, abertos }),
861                  maxTokens: 300,
862                  timeoutMs: 30000,
863                })
864                if (!r.isAnswered) {
865                  log(`painel: checker: o modelo nao respondeu (${r.reason})`)
866                  return
867                }
868                // So `aberto -> resolvido`, dentro do update: o estado pode ter mudado enquanto o
869                // modelo pensava (Faz agora, limpar).
870                const resolvidos = new Set(lerResolvidosChecker(r.text, abertos.map(i => i.id)) as number[])
871                if (resolvidos.size > 0) {
872                  await update($, painelDeixado, x => {
873                    const y = deixadoInteiro(x)
874                    return { ...y, itens: y.itens.map(i => (i.estado === 'aberto' && resolvidos.has(i.id) ? { ...i, estado: 'resolvido' as const } : i)) }
875                  })
876                }
877                if (acharNovos) await anotar(io, lerRespostaChecker(r.text) as string[], 'segundo modelo')
878              } finally {
879                try {
880                  await update($, painelDeixado, (d) => ({ ...d, checando: false }));
881                } catch {
882                  // sem estado, o aviso de checando some quando o pane redesenhar
883                }
884              }
885            }
886            checagem().catch((err: unknown) => log(`painel: checker: ${String(err)}`))
887          }
888        }
889      } catch {
890        // o deixado nunca quebra o turno
891      }
892    }
893    return next(e)
894  })
895
896  // A conversa compactada e outra janela: mostra a contagem da propria compactacao na hora e
897  // pede medicao nova (o engine troca a conversa depois que este hook volta, entao uma leitura
898  // agora ainda seria a velha).
899  // O matcher casa qualquer gatilho: register.ts ja tem um session.compact sem matcher.
900  on('session.compact', { trigger: /.*/ }, async ($, e, next) => {
901    const feito = await next(e)
902    try {
903      if (e.agentId !== undefined || e.trigger === 'precompute' || feito.messages === undefined) return feito
904      const depois = feito.tokensAfter
905      medirPendente = true
906      acordar()
907      await update($, painelStats, raw => {
908        const s = inteiro(raw)
909        const medida = depois !== undefined && s.ctxJanela !== null && s.ctxJanela > 0
910        return {
911          ...s,
912          medirDeNovo: true,
913          ctxTokens: depois ?? s.ctxTokens,
914          ctxPct: medida ? (depois / (s.ctxJanela ?? 1)) * 100 : s.ctxPct,
915          fatias: medida ? [] : s.fatias,
916        }
917      })
918    } catch {
919      // o contador nunca quebra a compactacao
920    }
921    return feito
922  })
923
924  // O pane do /painel: fluxos em curso, onde foi o contexto, cache, subagentes e custo. Toda
925  // leitura que falta vira uma frase no proprio painel; excecao devolve next(e).
926  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
927    try {
928      const { Box, Text, Button } = $.ui.resolve(e)
929      const s = inteiro(await read($, painelStats))
930      const dados = await read($, faixaDados)
931      const deixado = deixadoInteiro(await read($, painelDeixado))
932      const agora = await $.clock.now()
933      const interior = Math.max(16, e.props.bodyColumns - 4)
934      const visto = e.props.view?.agentId
935      const corte = (texto: string, margem: number): string => rotuloSubagente(texto, Math.max(8, interior - margem))
936
937      const linha = (chave: string, rotulo: string, valor: string, cor: string) => (
938        <Box key={chave} flexDirection="row" justifyContent="space-between">
939          <Text color={COR.ambar} dimColor>{rotulo}</Text>
940          <Text color={cor} bold>{valor}</Text>
941        </Box>
942      )
943      const painel = (chave: string, cor: string, titulo: string, filhos: unknown) => (
944        <Box key={chave} flexDirection="column" borderStyle="single" borderColor={cor} paddingX={1}>
945          <Text backgroundColor={cor} color="#000000" bold>{` ${titulo} `}</Text>
946          {filhos}
947        </Box>
948      )
949      const linhasAgentes = (limite: number) =>
950        [...s.agentes]
951          .sort((a, b) => b.iniciadoEm - a.iniciadoEm)
952          .slice(0, limite)
953          .map(a => {
954            const roda = emAndamento(a, agora)
955            const cor = roda ? COR.verde : a.falhou ? COR.vermelho : COR.ciano
956            const seta = a.id === visto ? '▶ ' : ''
957            return (
958              <Box key={`ag-${a.id}`} flexDirection="column">
959                <Box flexDirection="row" justifyContent="space-between">
960                  <Text color={cor} bold>{corte(`${seta}${roda ? '●' : a.falhou ? '✗' : '✓'} ${a.rotulo}`, 7)}</Text>
961                  <Text color={cor}>{mmss((roda ? agora : (a.fimEm ?? a.vistoEm)) - a.iniciadoEm)}</Text>
962                </Box>
963                <Text dimColor>{corte(`  ${roda ? 'trabalhando' : a.falhou ? 'parou' : 'pronto'} · ${a.tipo} · ${a.modelo ?? 'modelo desconhecido'} · ${a.ferramentas} ferramentas`, 0)}</Text>
964              </Box>
965            )
966          })
967
968      // O transcript de um subagente esta em tela: o pane e dele, e contexto e cache sao da principal.
969      if (visto !== undefined) {
970        const a = s.agentes.find(x => x.id === visto)
971        const roda = a !== undefined && emAndamento(a, agora)
972        const cor = a === undefined ? COR.ambar : roda ? COR.verde : a.falhou ? COR.vermelho : COR.ciano
973        return (
974          <Box flexDirection="column">
975            {painel('p-agente', COR.ciano, 'Subagente em tela', [
976              <Text key="nome" color={cor} bold>{corte(a?.rotulo ?? `agente ${visto.slice(0, 6)}`, 0)}</Text>,
977              a === undefined ? <Text key="sem" dimColor>Sem atividade vista ainda</Text> : null,
978              a !== undefined ? linha('status', 'Status', roda ? 'trabalhando' : a.falhou ? 'parou' : 'pronto', cor) : null,
979              a !== undefined ? linha('tempo', 'Tempo', mmss((roda ? agora : (a.fimEm ?? a.vistoEm)) - a.iniciadoEm), cor) : null,
980              a !== undefined ? linha('tipo', 'Tipo', a.tipo, COR.ciano) : null,
981              a !== undefined ? linha('modelo', 'Modelo', a.modelo ?? 'desconhecido', COR.ciano) : null,
982              a !== undefined ? linha('ferr', 'Ferramentas', `${a.ferramentas}`, COR.ciano) : null,
983              a !== undefined ? linha('lidos', 'Tokens lidos', compacto(a.tokensLidos), COR.ciano) : null,
984              a !== undefined ? linha('escritos', 'Tokens escritos', compacto(a.tokensSaida), COR.verde) : null,
985              <Text key="nota" dimColor>Os tokens sobem cada vez que ele termina uma rodada</Text>,
986            ])}
987            {painel('p-agentes', COR.ambar, 'Todos os subagentes', linhasAgentes(AGENTES_NO_PANE))}
988            <Text dimColor>Contexto e cache são da sessão principal. Volte a ela para vê-los.</Text>
989          </Box>
990        )
991      }
992
993      // Fluxos em curso (D4): o que esta aberto neste repositorio, lido de faixa-dados.cjs.
994      const fluxos = dados?.fluxos ?? []
995      // Leitura que falhou (exit, timeout, JSON) nao se mostra como "nenhum": vazio nao e ok.
996      const painelFluxos = painel('p-fluxos', COR.ciano, 'Fluxos em curso', dados === null
997        ? <Text color={COR.ambar}>Leitura dos fluxos indisponível agora; tenta de novo no fim do próximo turno</Text>
998        : fluxos.length === 0
999        ? <Text dimColor>Nenhum fluxo em curso</Text>
1000        : fluxos.map(f => {
1001            const partes = [f.etapa]
1002            if (f.tarefas !== null && f.tarefas_ok !== null) partes.push(`${f.tarefas_ok}/${f.tarefas}`)
1003            if (f.em_voo.length > 0) partes.push(`${f.em_voo.length} em voo`)
1004            const direita = rotuloSubagente(partes.join(' · '), Math.max(24, Math.floor(interior / 2)))
1005            return (
1006              <Box key={`fl-${f.slug}`} flexDirection="row" justifyContent="space-between">
1007                <Text color={COR.ciano} bold>{rotuloSubagente(slugSemData(f.slug), Math.max(8, interior - largura(direita) - 2))}</Text>
1008                <Text>{direita}</Text>
1009              </Box>
1010            )
1011          }))
1012
1013      // Onde foi o contexto: maiores primeiro, depois o livre e a reserva; as ferramentas
1014      // carregadas sob demanda ficam fora da janela e so sao listadas.
1015      const ocupadas = s.fatias.filter(f => f.tipo === 'used').sort((a, b) => b.tokens - a.tokens)
1016      const sobras = s.fatias.filter(f => f.tipo === 'free' || f.tipo === 'buffer')
1017      const sobDemanda = s.fatias.filter(f => f.tipo === 'deferred').reduce((soma, f) => soma + f.tokens, 0)
1018      const desenhadas = [...ocupadas, ...sobras]
1019      const celulas = fatias(desenhadas, interior);
1020      const janela = desenhadas.reduce((soma, f) => soma + f.tokens, 0)
1021      const tinta = (f: Fatia): string => (f.tipo === 'used' ? (CORES_FATIA[ocupadas.indexOf(f) % CORES_FATIA.length] ?? COR.ciano) : COR.cinza)
1022      const celula = (f: Fatia): string => (f.tipo === 'used' ? '█' : f.tipo === 'buffer' ? '▒' : '░')
1023      const restoUsado = ocupadas.slice(FATIAS_NA_LEGENDA).reduce((soma, f) => soma + f.tokens, 0)
1024      const legenda = [...ocupadas.slice(0, FATIAS_NA_LEGENDA), ...sobras]
1025      const corCtx = (s.ctxPct ?? 0) > 80 ? COR.vermelho : COR.verde
1026      const painelContexto = painel('p-contexto', COR.ambar, 'Onde foi o contexto', [
1027        linha('usado', 'Usado', s.ctxPct === null ? '--' : `${Math.round(s.ctxPct)}% de ${compacto(s.ctxJanela ?? 0)}`, corCtx),
1028        desenhadas.length === 0 ? <Text key="indisp" color={COR.ambar}>Medição indisponível</Text> : null,
1029        desenhadas.length > 0 ? (
1030          <Box key="barra" flexDirection="row">
1031            {desenhadas.map((f, i) => ((celulas[i] ?? 0) > 0 ? <Text key={`c-${i}`} color={ocupadas.indexOf(f) >= FATIAS_NA_LEGENDA ? COR.cinza : tinta(f)}>{celula(f).repeat(celulas[i] ?? 0)}</Text> : null))}
1032          </Box>
1033        ) : null,
1034        ...legenda.map((f, i) => (
1035          <Box key={`lg-${i}`} flexDirection="row" justifyContent="space-between">
1036            <Text color={tinta(f)}>{`${celula(f) === '█' ? '■' : celula(f)} ${rotuloSubagente(f.nome, Math.max(8, interior - 14))}`}</Text>
1037            <Text color={tinta(f)} bold>{`${compacto(f.tokens)} ${`${Math.round((f.tokens / Math.max(1, janela)) * 100)}%`.padStart(4)}`}</Text>
1038          </Box>
1039        )),
1040        restoUsado > 0 ? <Text key="resto" dimColor>{`■ ${ocupadas.length - FATIAS_NA_LEGENDA} itens menores, ${compacto(restoUsado)}`}</Text> : null,
1041        sobDemanda > 0 ? <Text key="demanda" dimColor>{`+ ${compacto(sobDemanda)} em ferramentas sob demanda`}</Text> : null,
1042      ])
1043
1044      // Cache de prompt: quente enquanto a ultima requisicao ainda cabe na vida do cache.
1045      const cache = cacheDe({ modelo: s.modelo, ctxTokens: s.ctxTokens, ultimaRequisicaoMs: s.ultimaRequisicaoMs, agora, ttlMs: s.ttlMs })
1046      const painelCache = painel('p-cache', COR.ambar, 'Cache de prompt', [
1047        s.ultimaRequisicaoMs === 0 ? <Text key="sem" dimColor>Medido depois do próximo turno</Text> : null,
1048        s.ultimaRequisicaoMs !== 0 ? linha('agora', 'Agora', cache.quente ? `quente, faltam ${restante(cache.restanteMs)}` : 'frio', cache.quente ? COR.vermelho : COR.ciano) : null,
1049        s.ultimaRequisicaoMs !== 0 ? linha('quente', 'Próxima mensagem com o cache quente', dinheiro(cache.custoQuente), COR.verde) : null,
1050        s.ultimaRequisicaoMs !== 0 ? linha('frio', 'Próxima mensagem com o cache frio', dinheiro(cache.custoFrio), cache.quente ? COR.ambar : COR.vermelho) : null,
1051        <Text key="nota" dimColor>{`Estimativa: reenvio da conversa a preço de lista, cache de ${s.ttlMs > 5 * MIN ? '1h' : '5m'}`}</Text>,
1052      ])
1053
1054      // Mapa da sessao (D13): so o que foi escrito por Edit, Write e NotebookEdit, skills, servicos
1055      // MCP e subagentes. Ler e Bash nao entram; fora de fluxo nada fica vermelho.
1056      const mapa = { ...mapaVazio(), ...(await read($, painelMapa)) } as RainforestMindPainelMapa
1057      const fim = (texto: string, max: number): string => (largura(texto) > max ? `…${texto.slice(-(Math.max(4, max) - 1))}` : texto)
1058      const secao = (chave: string, titulo: string, itens: unknown[], vazio: string) => [
1059        <Text key={`mt-${chave}`} color={COR.ambar} dimColor>{titulo}</Text>,
1060        ...(itens.length === 0 ? [<Text key={`mv-${chave}`} dimColor>{`  ${vazio}`}</Text>] : itens),
1061      ]
1062      const painelMapa_ = painel('p-mapa', COR.ciano, 'Mapa da sessão', [
1063        ...secao('arq', 'Arquivos escritos', mapa.arquivos.map((a, i) => (
1064          <Box key={`ma-${i}`} flexDirection="column">
1065            <Text color={a.desvio ? COR.vermelho : COR.verde} bold={a.desvio}>{fim(`${a.desvio ? '✗' : '✓'} ${a.caminho}`, interior - 2)}</Text>
1066            {a.desvio ? <Text color={COR.vermelho}>{'  fora dos arquivos do plano'}</Text> : null}
1067          </Box>
1068        )), 'nenhum'),
1069        ...secao('sk', 'Skills', mapa.skills.map((x, i) => <Text key={`ms-${i}`} color={COR.ciano}>{fim(`  ${x}`, interior)}</Text>), 'nenhuma'),
1070        ...secao('sv', 'Serviços MCP', mapa.servicos.map((x, i) => <Text key={`mc-${i}`} color={COR.ciano}>{fim(`  ${x}`, interior)}</Text>), 'nenhum'),
1071        ...secao('sa', 'Subagentes chamados', mapa.subagentes.map((x, i) => <Text key={`mg-${i}`} color={COR.ciano}>{fim(`  ${x}`, interior)}</Text>), 'nenhum'),
1072        <Text key="mnota" dimColor>Escrita feita por Bash não é detectada; só Edit, Write e NotebookEdit entram aqui.</Text>,
1073      ])
1074
1075      // Deixado para depois (D7): o que Claude adiou, escreveu como pendencia ou o segundo modelo
1076      // achou faltando. "Faz agora" so preenche o prompt; quem envia e a pessoa.
1077      const abertos = deixado.itens.filter(i => i.estado === 'aberto')
1078      const fazAgora = async (id: number, texto: string): Promise<void> => {
1079        try {
1080          const preenchido = await $.prompt.fill({ text: rascunhoFazAgora(texto) as string, mode: 'append' })
1081          // Prompt que recusou o texto deixa o item aberto: nada chegou a pessoa.
1082          if (!preenchido?.isFilled) return
1083          await update($, painelDeixado, d => ({ ...deixadoInteiro(d), itens: deixadoInteiro(d).itens.map(i => (i.id === id ? { ...i, estado: 'enviado' as const } : i)) }))
1084        } catch (err) {
1085          try {
1086            $.ui.log(`painel: deixado: ${String(err)}`, { to: 'debug' })
1087          } catch {
1088            // sem log, sem problema
1089          }
1090        }
1091      }
1092      const limpar = async (): Promise<void> => {
1093        try {
1094          await update($, painelDeixado, d => ({ ...deixadoInteiro(d), itens: [] }))
1095        } catch {
1096          // limpar nunca quebra o pane
1097        }
1098      }
1099      const painelDeixadoTela = painel('p-deixado', COR.vermelho, 'Deixado para depois', [
1100        deixado.checando ? <Text key="checando" color={COR.ambar}>Checando com o segundo modelo…</Text> : null,
1101        abertos.length === 0 && !deixado.checando ? <Text key="nada" dimColor>Nada marcado. O trabalho que Claude deixar para depois aparece aqui.</Text> : null,
1102        ...abertos.map((item, i) => (
1103          <Box key={`dx-${item.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
1104            <Text color={COR.vermelho} bold>{`D${item.id} · ${item.origem}`}</Text>
1105            <Text wrap="wrap">{item.texto}</Text>
1106            <Button key={`faz-${item.id}`} label="Faz agora (só preenche o prompt)" onPress={() => fazAgora(item.id, item.texto)} />
1107          </Box>
1108        )),
1109        abertos.length > 0 ? <Button key="deixado-limpar" label="Limpar tudo" onPress={limpar} /> : null,
1110        <Text key="dnota" dimColor>{`Checagem do segundo modelo: ${deixado.checar ? 'ligada' : 'desligada'} (/painel checar ligar|desligar)`}</Text>,
1111      ])
1112
1113      const lidos = s.tokensNovos + s.tokensCacheLido + s.tokensCacheEscrito
1114      const doCache = lidos === 0 ? 0 : Math.round((s.tokensCacheLido / lidos) * 100)
1115      return (
1116        <Box flexDirection="column">
1117          {painelFluxos}
1118          {painelDeixadoTela}
1119          {painelMapa_}
1120          {painelContexto}
1121          {painelCache}
1122          {painel('p-agentes', COR.ambar, 'Subagentes', s.agentes.length === 0 ? <Text dimColor>Nenhum iniciado ainda</Text> : linhasAgentes(AGENTES_NO_PANE))}
1123          {painel('p-custo', COR.ambar, 'Custo e tokens', [
1124            linha('custo', 'Custo da sessão', dinheiro(s.custoUsd), COR.ambar),
1125            linha('lidos', 'Tokens lidos', compacto(lidos), COR.ciano),
1126            linha('servido', '  servidos do cache', `${doCache}%`, COR.ciano),
1127            linha('escritos', 'Tokens escritos', compacto(s.tokensSaida), COR.verde),
1128            linha('turnos', 'Turnos', `${s.turnos}`, COR.ciano),
1129            <Text key="nota" dimColor>Tokens e turnos contam desde que o mod carregou</Text>,
1130          ])}
1131        </Box>
1132      )
1133    } catch {
1134      return next(e)
1135    }
1136  })
1137
1138  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1139    try {
1140      if (e.props.hasSurvey || (await read($, painelOculto))) return next(e)
1141      const { Box, Text } = $.ui.resolve(e)
1142      const s = inteiro(await read($, painelStats))
1143      const agora = await $.clock.now()
1144      const cols = e.props.bodyColumns
1145      const visto = e.props.view?.agentId
1146
1147      // O transcript de um subagente esta em tela: a barra e dele, e o contexto e o da sessao principal.
1148      if (visto !== undefined) {
1149        const a = s.agentes.find(x => x.id === visto)
1150        const roda = a !== undefined && emAndamento(a, agora)
1151        const cor = a === undefined ? COR.ambar : roda ? COR.verde : a.falhou ? COR.vermelho : COR.ciano
1152        const partes: { texto: string; cor: string }[] = [
1153          { texto: ' Subagente ', cor: COR.ciano },
1154          { texto: rotuloSubagente(a?.rotulo ?? `agente ${visto.slice(0, 6)}`, 24), cor },
1155        ]
1156        if (a === undefined) {
1157          partes.push({ texto: 'Sem atividade vista ainda', cor: COR.ambar })
1158        } else {
1159          partes.push(
1160            { texto: 'Status ' + (roda ? 'trabalhando' : a.falhou ? 'parou' : 'pronto'), cor },
1161            { texto: 'Tempo ' + mmss((roda ? agora : (a.fimEm ?? a.vistoEm)) - a.iniciadoEm), cor },
1162            { texto: 'Ferram. ' + a.ferramentas, cor },
1163            { texto: 'Tokens ' + compacto(a.tokensLidos + a.tokensSaida), cor },
1164            { texto: 'Modelo ' + rotuloSubagente(a.modelo ?? 'desconhecido', 24), cor },
1165          )
1166        }
1167        partes.push({ texto: 'Sessão principal: contexto ' + (s.ctxPct === null ? '--' : Math.round(s.ctxPct) + '%'), cor: COR.ambar })
1168        const ficam: { texto: string; cor: string }[] = []
1169        let usado = 0
1170        for (const p of partes) {
1171          const custo = largura(p.texto) + (ficam.length > 0 ? 2 : 0)
1172          if (ficam.length > 0 && usado + custo > cols) break
1173          ficam.push(ficam.length === 0 ? { ...p, texto: cortar(p.texto, cols) } : p)
1174          usado += custo
1175        }
1176        return (
1177          <Box flexDirection="row" height={1} overflow="hidden">
1178            {ficam.map((p, i) => (
1179              <Box key={`a${i}`} marginLeft={i === 0 ? 0 : 2}>
1180                <Text color={p.cor} wrap="truncate-end">{p.texto}</Text>
1181              </Box>
1182            ))}
1183          </Box>
1184        )
1185      }
1186
1187      let relogio: string | null = null
1188      try {
1189        relogio = linhaDoRelogio(await read($, relogioJornada), await read($, relogioSessoes), agora)
1190      } catch {
1191        // sem relogio, a barra segue
1192      }
1193      const figuras = figurasDaBarra(
1194        {
1195          trabalhando: e.props.isWorking,
1196          tokens: s.tokensNovos + s.tokensCacheLido + s.tokensCacheEscrito + s.tokensSaida,
1197          custoUsd: s.custoUsd,
1198          ctxPct: s.ctxPct ?? undefined,
1199          ctxTokens: s.ctxTokens ?? undefined,
1200          modelo: s.modelo,
hooks/register.ts 42 lines
1// Mod da abertura (fluxo 2026-10-02-mod-regras-inteiras): as regras e a memoria entram
2// como secao do system prompt, sem o teto de entrega do hook SessionStart. Toda a logica
3// mora em ./abertura-mod-puro.mjs (testavel sem engine); aqui so se liga os hooks.
4// Um modulo de mod com `import()` dinamico nao carrega: o import abaixo e estatico.
5// O engine tambem recusa `$` passado como argumento: a logica recebe so tres funcoes
6// tiradas dele, escritas no ponto de chamada.
7import type { Register } from 'claude-code'
8import { criarAbertura } from './abertura-mod-puro.mjs'
9
10export const register: Register = on => {
11  const abertura = criarAbertura()
12
13  on('session.end', ($, e, next) => {
14    abertura.aoEncerrar(e.reason)
15    return next(e)
16  })
17
18  on('prompt.compose', async ($, e, next) => {
19    const r = await next(e)
20    const texto = await abertura.obter({
21      rodar: (argv, init) => $.process.run(argv, init),
22      cwd: () => $.session.cwd(),
23      raiz: $.plugin.root,
24    })
25    return abertura.comSecao(r, texto)
26  })
27
28  on('classic.SessionStart', async ($, e, next) => {
29    const r = await next(e)
30    const texto = await abertura.obter({
31      rodar: (argv, init) => $.process.run(argv, init),
32      cwd: () => $.session.cwd(),
33      raiz: $.plugin.root,
34    })
35    return abertura.semAbertura(r, texto)
36  })
37
38  // Conta com sec-default: o compose e o SessionStart do plugin sao barrados por politica da
39  // organizacao. O mod nao tenta entregar a abertura por outro canal (nenhum handler de
40  // engine.create, session.start, prompt.submit ou session.compact); ver abertura-mod-puro.mjs.
41}
42
hooks/deixado-puro.mjs 141 lines
1// Logica pura do "deixado para depois" do mod: frases de adiamento em portugues e
2// ingles, marcadores em arquivo escrito e o prompt do checker. ES module sem Node e sem
3// o objeto do engine: tudo entra por argumento.
4// Contrato: docs/rainforest/planos/2026-10-07-desk-no-mod.md, tarefa 2.
5// Frases e marcadores partem das regex do terminal-desk 0.2.1 (licenca MIT, titular
6// ClariSortAi); texto da licenca e origem em NOTICE, na raiz do plugin.
7
8// Um turno com menos ferramentas que isso trabalhou pouco para valer um checker.
9export const CHECAR_MIN_FERRAMENTAS = 5;
10
11import { semControle } from './faixa-puro.mjs';
12
13// Como uma resposta diz que adiou trabalho. Roda sobre texto normalizado por
14// `semCitacao` (sem acento, minusculo, sem o que esta entre aspas ou crases).
15const DITO = /\b(?:por enquanto|fic(?:a|am|ou|aram|ara|arao) (?:para|pra) depois|ficou faltando|ficaram faltando|deixei para depois|proxima (?:fase|etapa|rodada|passada)|nao (?:rodei|testei|verifiquei|implementei|executei|conferi|consegui)|sem (?:rodar|testar|verificar)|fora do escopo|em outra (?:rodada|passada|etapa)|placeholder|for now|follow[- ]up|out of scope|not yet|i (?:didn't|did not|haven't|have not|skipped|left)\b|(?:do|handle|add|fix|revisit|address|tackle) (?:that|this|it|them|those) later|in a later (?:pass|step|turn|change|pr)|still needs?|remains? to be|stubbed|untested|not (?:verified|tested|implemented|wired up))/i;
16
17// "Nada fica para depois", "nao ficou faltando": a negacao desfaz o adiamento, entao a
18// expressao negada sai do texto antes de DITO rodar.
19const NEGADO = /\b(?:nada|nenhum[a-z]*|nem|nao)\s+(?:[a-z]+\s+){0,2}?fic(?:a|am|ou|aram|ara|arao)\s+(?:(?:para|pra) depois|faltando)/g;
20
21// Como trabalho adiado aparece quando ja foi escrito num arquivo. `TODO` em maiusculas
22// e palavra portuguesa neste repo ("corpo de TODO heredoc"), por isso o marcador so conta
23// seguido de `:` ou `(`.
24const MARCA = /\b(?:TODO|FIXME|XXX)\s*[:(]|not implemented|NotImplemented|\.skip\(|\bx(?:it|describe)\(|@pytest\.mark\.skip/;
25
26// O que Claude cita entre aspas ou mostra como codigo nao e Claude adiando trabalho.
27const semCitacao = (parte) =>
28  parte
29    .replace(/"[^"]*"|“[^”]*”|`[^`]*`/g, ' ')
30    .replace(/’/g, "'")
31    .normalize('NFD')
32    .replace(/[̀-ͯ]/g, '')
33    .toLowerCase();
34
35// Decisao que sobe para a pessoa (regra 16: `Q1`, `**Q2**`, `Q3:`) espera a palavra dela,
36// nao e trabalho que Claude adiou.
37const LINHA_Q = /^[\s>*#-]*Q\d+\b/;
38const semCodigo = (texto) => String(texto ?? '').replace(/```[\s\S]*?```/g, ' ');
39
40// O relato pergunta decisao a pessoa: uma linha abre com `Q<n>`, fora de bloco de codigo.
41export const perguntaDecisao = (relato) => semCodigo(relato).split(/\n+/).some(linha => LINHA_Q.test(linha));
42
43// As frases de uma resposta que adiam trabalho, blocos de codigo e linhas `Q<n>` fora; ate 3,
44// 200 cars.
45export const deferimentos = (resposta) =>
46  semCodigo(resposta)
47    .split(/\n+/)
48    .filter(linha => !LINHA_Q.test(linha))
49    .join('\n')
50    .split(/(?<=[.!?])\s+|\n+/)
51    // Texto do modelo: ESC, BEL e override bidi nunca chegam ao Text, ao toast nem ao prompt.
52    .map(parte => semControle(parte.replace(/^[\s>*#-]+/, '').replace(/\*\*/g, '')).trim())
53    .filter(parte => parte.length > 12 && DITO.test(semCitacao(parte).replace(NEGADO, ' ')))
54    .map(parte => parte.slice(0, 200))
55    .slice(0, 3);
56
57// As linhas de um arquivo escrito que carregam marcador de trabalho adiado.
58export const marcadoresEmArquivo = (conteudo) =>
59  String(conteudo ?? '')
60    .split(/\r?\n/)
61    .filter(linha => MARCA.test(linha))
62    .map(linha => semControle(linha).trim().slice(0, 200))
63    .slice(0, 3);
64
65const plural = (n, um, varios) => n + ' ' + (n === 1 ? um : varios);
66
67// `Bash x12 (2 erros), Edit x3, Write x1 (1 negada)`: cada ferramenta do turno, na ordem
68// em que apareceu, com as falhas. Entrada: [{ tool, deny, isError }].
69export const resumirFerramentas = (ferramentas) => {
70  const porNome = new Map();
71  for (const f of ferramentas ?? []) {
72    const nome = String((f && f.tool) || '?');
73    const c = porNome.get(nome) ?? { n: 0, erros: 0, negadas: 0 };
74    c.n += 1;
75    if (f && f.deny) c.negadas += 1;
76    else if (f && f.isError) c.erros += 1;
77    porNome.set(nome, c);
78  }
79  return [...porNome]
80    .map(([nome, c]) => {
81      const falhas = [];
82      if (c.erros > 0) falhas.push(plural(c.erros, 'erro', 'erros'));
83      if (c.negadas > 0) falhas.push(plural(c.negadas, 'negada', 'negadas'));
84      return nome + ' x' + c.n + (falhas.length > 0 ? ' (' + falhas.join(', ') + ')' : '');
85    })
86    .join(', ');
87};
88
89// O que o segundo modelo (checker) le: o pedido, as ferramentas do turno com as falhas, o fim
90// do relato e os itens ainda abertos. Em portugues; ate 3 linhas de pendencia nova, uma linha
91// `RESOLVIDO <id>: <evidencia>` por item aberto que o turno resolveu, ou a palavra NENHUM.
92export const montarPromptChecker = ({ pedido, relato, ferramentas, abertos }) =>
93  [
94    'Voce compara o que a pessoa pediu a um assistente de codigo com o relato final dele e com as ferramentas que ele usou.',
95    'Liste cada coisa que o pedido claramente exigia e que o relato mostra que nao foi feita, foi adiada ou foi feita so em parte.',
96    'No maximo 3 linhas, cada uma com menos de 20 palavras e comecando por um verbo. Sem numeracao e sem comentario.',
97    'Pergunta de decisao que o relato faz a pessoa (Q1, Q2...) nao e pendencia: nao liste.',
98    'Para cada item de PENDENCIAS ABERTAS que o pedido, o relato ou as ferramentas mostram feito, respondido ou que deixou de valer, escreva uma linha RESOLVIDO <id>: <evidencia em ate 8 palavras>.',
99    'Se nada ficou por fazer e nada foi resolvido, responda apenas com a palavra NENHUM.',
100    '',
101    'PENDENCIAS ABERTAS:',
102    (abertos ?? []).map(i => i.id + ': ' + String(i.texto ?? '').slice(0, 200)).join('\n') || '(nenhuma)',
103    '',
104    'PEDIDO:',
105    String(pedido ?? '').slice(0, 4000),
106    '',
107    'FERRAMENTAS DO TURNO:',
108    resumirFerramentas(ferramentas) || '(nenhuma)',
109    '',
110    'RELATO FINAL:',
111    String(relato ?? '').slice(-6000),
112  ].join('\n');
113
114const RESOLVIDO = /^[\s*-]*RESOLVIDO\s+D?(\d+)\b/i;
115
116// A resposta do checker em itens: ate 3, sem numeracao, sem a palavra NENHUM e sem as linhas
117// RESOLVIDO, que nao comem a cota dos novos.
118export const lerRespostaChecker = (texto) =>
119  String(texto ?? '')
120    .split('\n')
121    .filter(linha => !RESOLVIDO.test(linha))
122    .map(linha => semControle(linha).replace(/^[\s\d.)*-]+/, '').trim().slice(0, 200))
123    .filter(linha => linha !== '' && !['nenhum', 'none'].includes(linha.replace(/[.\s]+/g, '').toLowerCase()))
124    .slice(0, 3);
125
126// Os ids que o checker deu por resolvidos, so os de `idsAbertos`: o id vem do modelo.
127export const lerResolvidosChecker = (texto, idsAbertos) => {
128  const abertos = new Set(idsAbertos ?? []);
129  const ids = String(texto ?? '')
130    .split('\n')
131    .map(linha => RESOLVIDO.exec(linha))
132    .filter(m => m !== null)
133    .map(m => Number(m[1]))
134    .filter(id => abertos.has(id));
135  return [...new Set(ids)];
136};
137
138// Rascunho do prompt do botao "Faz agora". Nunca e enviado sozinho: so preenche.
139export const rascunhoFazAgora = (frase) =>
140  'Voce deixou isto para depois: "' + String(frase ?? '').slice(0, 200) + '". Faça agora e me diga o que rodou para provar.';
141
hooks/faixa-puro.mjs 64 lines
1// Biblioteca de largura em celulas de terminal: ES module sem Node, sem acesso a ambiente.
2// Guardou o nome da faixa de foco que o mod desenhava antes da barra de sessao (o foco, as
3// linhas de fluxo e as Q sairam, D2); hoje so serve a hooks/painel-puro.mjs: `largura`,
4// `cortar` e `semControle`. Renomear o arquivo e escopo a parte.
5
6// Largura em celulas de terminal: emoji e CJK valem 2; combinantes e seletores de
7// variacao valem 0. Um caractere seguido de U+FE0F (apresentacao de emoji) vale 2.
8const LARGOS = [
9  [0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
10  [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
11  [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
12  [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
13  [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
14  [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
15  [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd],
16  [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
17  [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
18  [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50],
19  [0x2b55, 0x2b55], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e],
20  [0x1f191, 0x1f19a], [0x1f200, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb],
21  [0x1f900, 0x1faff], [0x20000, 0x3fffd],
22];
23const ZERO = [
24  [0x0300, 0x036f], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff], [0x200b, 0x200f],
25  [0x20d0, 0x20ff], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f], [0xe0100, 0xe01ef],
26];
27const dentro = (tabela, c) => tabela.some(([a, b]) => c >= a && c <= b);
28
29function larguraDe(c, seguinte) {
30  if (dentro(ZERO, c)) return 0;
31  if (dentro(LARGOS, c) || seguinte === 0xfe0f) return 2;
32  return 1;
33}
34
35/** @param {unknown} str */
36export function largura(str) {
37  const cps = Array.from(String(str ?? ''), ch => ch.codePointAt(0));
38  let total = 0;
39  for (let i = 0; i < cps.length; i++) total += larguraDe(cps[i], cps[i + 1]);
40  return total;
41}
42
43export function cortar(str, cols) {
44  if (largura(str) <= cols) return str;
45  if (cols < 1) return '';
46  const cps = Array.from(str);
47  let saida = '';
48  let w = 0;
49  for (let i = 0; i < cps.length; i++) {
50    const c = larguraDe(cps[i].codePointAt(0), cps[i + 1] ? cps[i + 1].codePointAt(0) : 0);
51    if (w + c > cols - 1) break;
52    saida += cps[i];
53    w += c;
54  }
55  return saida + '…';
56}
57
58// Texto que vem do modelo (nome de subagente, titulo): ESC (sequencia ANSI), outros controles
59// C0/C1 e override bidi nunca chegam crus ao terminal.
60const CONTROLES = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2069\ufeff]/g;
61export function semControle(s) {
62  return String(s ?? '').replace(CONTROLES, ' ');
63}
64
hooks/mapa-puro.mjs 104 lines
1// Logica pura do mapa da sessao: o que foi escrito, que skill rodou, que servico MCP
2// e que subagente entrou. Sem `$`, sem Node, sem relogio: o mod chama com valores.
3// D16: so Edit, Write e NotebookEdit contam como escrita; escrita por Bash fica fora.
4
5import { semControle } from './faixa-puro.mjs';
6
7export const ESCRITORAS = new Set(['Edit', 'Write', 'NotebookEdit']);
8export const TETO = 50;
9
10// Controles C0/C1 e override bidi nunca chegam crus ao terminal: a funcao e a de faixa-puro.mjs.
11export { semControle };
12
13/** Caminho escrito pela chamada, ou null (leitura, Bash e o resto nao escrevem). */
14export function escritaDe(e) {
15  if (!ESCRITORAS.has(e.tool)) return null;
16  const c = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path;
17  return typeof c === 'string' && c !== '' ? semControle(c) : null;
18}
19
20/** Servidor de `mcp__<servidor>__<ferramenta>`, ou null. */
21export function servidorDe(tool) {
22  const m = /^mcp__(.+?)__.+$/.exec(String(tool ?? ''));
23  return m ? semControle(m[1]) : null;
24}
25
26export function mapaVazio() {
27  return { arquivos: [], skills: [], servicos: [], subagentes: [] };
28}
29
30// Estoura o teto: sai a entrada mais antiga sem desvio; so desvio sobrando, a mais antiga.
31function aparar(lista) {
32  const l = lista.slice();
33  while (l.length > TETO) {
34    const i = l.findIndex(x => typeof x === 'object' && !x.desvio);
35    l.splice(i < 0 ? 0 : i, 1);
36  }
37  return l;
38}
39
40function incluir(lista, nome) {
41  if (nome === '' || lista.includes(nome)) return { lista, novo: false };
42  return { lista: aparar([...lista, nome]), novo: true };
43}
44
45/**
46 * Evento: o `tool.call` cru (`tool` mais argumentos), `{ agente: true, description }`
47 * para o `agent.spawn`, ou `{ desvio: true, caminho }` para marcar arquivo fora do plano.
48 * Devolve `{ mapa, novo }`; `novo` so e true na 1a vez que a entrada aparece.
49 */
50export function registrar(mapa, evento) {
51  const m = { ...mapaVazio(), ...mapa };
52  if (evento && evento.agente === true) {
53    const r = incluir(m.subagentes, semControle(evento.description).trim());
54    return { mapa: { ...m, subagentes: r.lista }, novo: r.novo };
55  }
56  if (evento && evento.desvio === true) {
57    const caminho = semControle(evento.caminho);
58    const existe = m.arquivos.some(a => a.caminho === caminho);
59    const arquivos = existe
60      ? m.arquivos.map(a => (a.caminho === caminho ? { ...a, desvio: true } : a))
61      : aparar([...m.arquivos, { caminho, desvio: true }]);
62    return { mapa: { ...m, arquivos }, novo: !existe };
63  }
64  const caminho = escritaDe(evento);
65  if (caminho !== null) {
66    if (m.arquivos.some(a => a.caminho === caminho)) return { mapa: m, novo: false };
67    return { mapa: { ...m, arquivos: aparar([...m.arquivos, { caminho, desvio: false }]) }, novo: true };
68  }
69  if (evento.tool === 'Skill' && typeof evento.skill === 'string') {
70    const r = incluir(m.skills, semControle(evento.skill).trim());
71    return { mapa: { ...m, skills: r.lista }, novo: r.novo };
72  }
73  const servidor = servidorDe(evento.tool);
74  if (servidor !== null) {
75    const r = incluir(m.servicos, servidor);
76    return { mapa: { ...m, servicos: r.lista }, novo: r.novo };
77  }
78  return { mapa: m, novo: false };
79}
80
81/**
82 * Troca a chave de um arquivo do mapa: `de` (o caminho absoluto da escrita, que a falha aberta
83 * e a fila cheia registram) vira `para` (o relativo que o veredito devolveu), no mesmo lugar.
84 * Se `para` ja existe, a entrada de `de` some e o vermelho de qualquer uma das duas fica.
85 */
86export function trocarCaminho(mapa, de, para) {
87  const m = { ...mapaVazio(), ...mapa };
88  const origem = semControle(de);
89  const destino = semControle(para);
90  const i = m.arquivos.findIndex(a => a.caminho === origem);
91  if (i < 0 || origem === destino) return m;
92  const velho = m.arquivos[i];
93  const j = m.arquivos.findIndex(a => a.caminho === destino);
94  const arquivos = m.arquivos.slice();
95  if (j < 0) {
96    arquivos[i] = { ...velho, caminho: destino };
97  } else {
98    arquivos[j] = { ...arquivos[j], desvio: arquivos[j].desvio || velho.desvio };
99    arquivos.splice(i, 1);
100  }
101  return { ...m, arquivos };
102}
103
104
hooks/painel-puro.mjs 163 lines
1// Adaptado do terminal-desk 0.2.1 (licenca MIT, titular ClariSortAi); texto da licenca e
2// origem em NOTICE, na raiz do plugin.
3// Logica pura do painel do mod (barra de sessao e pane): preco e cache de prompt, fatias
4// do contexto, ritmo, formatacao e as figuras da barra. ES module sem Node, sem `$` e sem
5// relogio: o instante entra por argumento (`agora`, em ms), nunca por leitura.
6// Largura em celulas vem de faixa-puro.mjs (nunca copiada aqui).
7
8import { largura, cortar, semControle } from './faixa-puro.mjs';
9
10const MINUTO = 60_000;
11
12// Precos de lista da API em dolares por milhao de tokens de entrada e a parcela que uma
13// leitura de cache custa dela. A primeira linha que casa vence (cotacoes de 2026-09-25,
14// nao reconferidas).
15export const PRECOS = [
16  { casa: 'fable-5-1', entrada: 10, leitura: 0.025 },
17  { casa: 'mythos-5-1', entrada: 10, leitura: 0.025 },
18  { casa: 'fable', entrada: 10, leitura: 0.1 },
19  { casa: 'mythos', entrada: 10, leitura: 0.1 },
20  { casa: 'opus-5-5', entrada: 4, leitura: 0.05 },
21  { casa: 'opus', entrada: 5, leitura: 0.1 },
22  { casa: 'sonnet-5', entrada: 2, leitura: 0.1 },
23  { casa: 'sonnet', entrada: 3, leitura: 0.1 },
24  { casa: 'haiku', entrada: 1, leitura: 0.1 },
25];
26
27export function precoDe(modelo) {
28  const nome = String(modelo ?? '');
29  return PRECOS.find((p) => nome.includes(p.casa)) ?? null;
30}
31
32export function compacto(n) {
33  if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
34  return n >= 1000 ? (n / 1000).toFixed(1) + 'K' : String(n);
35}
36
37export function dinheiro(usd) {
38  if (typeof usd !== 'number' || !Number.isFinite(usd)) return '--';
39  return usd > 0 && usd < 0.005 ? '<$0.01' : '$' + usd.toFixed(2);
40}
41
42// Minutos e segundos que faltam, para a contagem se ver andar.
43export function restante(ms) {
44  const s = Math.ceil(Math.max(0, ms) / 1000);
45  return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
46}
47
48// Chamadas de ferramenta no ultimo minuto antes de `agora`.
49export function ritmoPorMinuto(carimbos, agora) {
50  return (Array.isArray(carimbos) ? carimbos : []).filter((t) => agora - t <= MINUTO).length;
51}
52
53// Cache de prompt: quente enquanto a ultima requisicao da sessao principal comecou dentro
54// da vida do cache (reenviar e leitura barata); frio depois (a janela inteira e escrita de
55// novo, a 1,25x no cache de 5 min e a 2x no de 1 h).
56export function cacheDe({ modelo, ctxTokens, ultimaRequisicaoMs, agora, ttlMs }) {
57  const restanteMs = ultimaRequisicaoMs === 0 ? 0 : Math.max(0, ultimaRequisicaoMs + ttlMs - agora);
58  const quente = restanteMs > 0;
59  const preco = precoDe(modelo);
60  const semDado = preco === null || typeof ctxTokens !== 'number';
61  const milhoes = (ctxTokens ?? 0) / 1_000_000;
62  const escrita = ttlMs > 5 * MINUTO ? 2 : 1.25;
63  return {
64    quente,
65    restanteMs,
66    custoQuente: semDado ? null : milhoes * preco.entrada * preco.leitura,
67    custoFrio: semDado ? null : milhoes * preco.entrada * escrita,
68  };
69}
70
71// Celulas por fatia pelo maior resto: a barra tem exatamente `larguraTotal` celulas.
72export function fatias(lista, larguraTotal) {
73  const total = lista.reduce((s, f) => s + f.tokens, 0);
74  if (total === 0) return lista.map(() => 0);
75  const exato = lista.map((f) => (f.tokens / total) * larguraTotal);
76  const celulas = exato.map(Math.floor);
77  let sobra = larguraTotal - celulas.reduce((s, n) => s + n, 0);
78  const ordem = exato.map((x, i) => ({ i, resto: x - Math.floor(x) })).sort((a, b) => b.resto - a.resto);
79  for (const { i } of ordem) {
80    if (sobra <= 0) break;
81    celulas[i] += 1;
82    sobra -= 1;
83  }
84  return celulas;
85}
86
87// Nome de subagente vem do modelo: controles e override bidi nunca chegam crus ao terminal.
88export function rotuloSubagente(nome, cols = 24) {
89  return cortar(semControle(String(nome ?? '')), cols);
90}
91
92const SEPARADOR = '  ';
93
94function barraContexto(pct) {
95  const n = 7;
96  const pos = Math.min(n - 1, Math.max(0, Math.round((pct / 100) * (n - 1))));
97  let b = '';
98  for (let i = 0; i < n; i++) b += i === pos ? '●' : i < pos ? '━' : '─';
99  return b;
100}
101
102// stats: { trabalhando, tokens, custoUsd, ctxPct, ctxTokens, modelo, ttlMs,
103//          ultimaRequisicaoMs, agora, deixado, carimbos, subagentes, turnos, erros }
104// relogio: linha pronta de `linhaRelogio` (string) ou null.
105// Devolve as figuras que cabem em `cols`, na ordem de exibicao: [{ id, texto, largura }].
106// Quando aperta, caem da direita; o estado e o relogio ficam por ultimo, o relogio por ultimo.
107export function figurasDaBarra(stats, relogio, cols) {
108  const s = stats ?? {};
109  const agora = s.agora ?? 0;
110  const c = cacheDe({
111    modelo: s.modelo,
112    ctxTokens: s.ctxTokens,
113    ultimaRequisicaoMs: s.ultimaRequisicaoMs ?? 0,
114    agora,
115    ttlMs: s.ttlMs ?? 5 * MINUTO,
116  });
117  const todas = [
118    ['estado', s.trabalhando ? '● trabalhando' : '○ pronto'],
119    ['tokens', 'Tokens ' + compacto(s.tokens ?? 0)],
120    ['custo', 'Custo ' + dinheiro(s.custoUsd ?? null)],
121  ];
122  if (typeof s.ctxPct === 'number') todas.push(['contexto', 'Contexto 0 ' + barraContexto(s.ctxPct) + ' 100 ' + Math.round(s.ctxPct) + '%']);
123  todas.push([
124    'cache',
125    c.quente
126      ? 'Cache ● quente ' + restante(c.restanteMs) + ' · reenvio ' + dinheiro(c.custoQuente) + ' (' + dinheiro(c.custoFrio) + ' se esfriar)'
127      : 'Cache ○ frio · reenvio ' + dinheiro(c.custoFrio),
128  ]);
129  if ((s.deixado ?? 0) > 0) todas.push(['deixado', 'Deixado ' + s.deixado]);
130  todas.push(['ritmo', 'Ferram./min ' + ritmoPorMinuto(s.carimbos, agora)]);
131  todas.push(['subagentes', 'Subagentes ' + (s.subagentes ?? 0)]);
132  todas.push(['turnos', 'Turnos ' + (s.turnos ?? 0)]);
133  todas.push(['erros', 'Erros ' + (s.erros ?? 0)]);
134  if (relogio) todas.push(['relogio', relogio]);
135
136  const figuras = todas.map(([id, texto]) => {
137    const t = semControle(String(texto));
138    return { id, texto: t, largura: largura(t) };
139  });
140  // Ordem em que as figuras tentam entrar: o relogio primeiro (e o ultimo a cair), o estado
141  // depois, o resto na ordem de exibicao. Cada uma entra se couber; a que nao cabe nao
142  // impede as seguintes. As demais so entram com o estado ja dentro: estreito demais para os
143  // dois prioritarios juntos, sobra o relogio sozinho, nunca uma figura menor no lugar do estado.
144  const prioridade = ['relogio', 'estado'];
145  const ordemDeEntrada = [...prioridade, ...figuras.map((f) => f.id).filter((id) => !prioridade.includes(id))];
146  const mantidas = new Set();
147  let usado = 0;
148  for (const id of ordemDeEntrada) {
149    const f = figuras.find((x) => x.id === id);
150    if (!f) continue;
151    if (!prioridade.includes(id) && !mantidas.has('estado')) continue;
152    const custo = f.largura + (mantidas.size > 0 ? largura(SEPARADOR) : 0);
153    if (usado + custo > cols) continue;
154    mantidas.add(id);
155    usado += custo;
156  }
157  if (mantidas.size === 0 && figuras.length > 0) {
158    const t = cortar(figuras[0].texto, cols);
159    return [{ id: figuras[0].id, texto: t, largura: largura(t) }];
160  }
161  return figuras.filter((f) => mantidas.has(f.id));
162}
163
hooks/relogio-puro.mjs 98 lines
1// Logica pura do relogio do mod (jornada e janela parada). ES module sem Node e sem
2// o objeto do engine: o relogio entra por argumento (`agora`, em ms), nunca por leitura.
3// Contrato: docs/rainforest/planos/2026-10-03-mod-jornada-relogio.md.
4
5export const LIMITE_EFETIVA_MIN = 540;
6export const HORA_NOITE = 19;
7export const HORA_FIM_MADRUGADA = 5;
8export const JANELA_MSG_MIN = 30;
9export const OCIOSIDADE_PADRAO_MIN = 45;
10
11const pad2 = (n) => String(n).padStart(2, '0');
12
13// Mesma forma de `hhmm` em scripts/jornada.cjs: `9h12`, `45 min`.
14export function hhmm(minutos) {
15  const total = Math.round(minutos);
16  const h = Math.floor(total / 60);
17  const m = total % 60;
18  return h ? h + 'h' + pad2(m) : m + ' min';
19}
20
21export function diaLocal(ms) {
22  const d = new Date(ms);
23  return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
24}
25
26export function horaLocal(ms) {
27  const d = new Date(ms);
28  return d.getHours() + 'h' + pad2(d.getMinutes());
29}
30
31// Ultimo segmento do caminho; o cwd real do Windows vem com barra invertida.
32function pastaDe(cwd) {
33  const partes = String(cwd || '').replace(/[\\/]+$/, '').split(/[\\/]/);
34  return partes[partes.length - 1] || '';
35}
36
37// jornada: { efetiva_min, ultimo_ms } | null
38// sessoes: { ociosidade_min, janelas: [{ cwd, desde }] } | null
39export function avaliarRelogio({ jornada, sessoes, agora }) {
40  let j = null;
41  if (jornada) {
42    const { efetiva_min, ultimo_ms } = jornada;
43    const hora = new Date(agora).getHours();
44    const longa = efetiva_min > LIMITE_EFETIVA_MIN;
45    const noite = (hora >= HORA_NOITE || hora < HORA_FIM_MADRUGADA) && agora - ultimo_ms <= JANELA_MSG_MIN * 60000;
46    const mesmoDia = diaLocal(ultimo_ms) === diaLocal(agora);
47    if (mesmoDia && (longa || noite)) j = { efetiva_min, hora: horaLocal(agora), dia: diaLocal(agora) };
48  }
49
50  let parada = null;
51  if (sessoes && Array.isArray(sessoes.janelas)) {
52    const limite = typeof sessoes.ociosidade_min === 'number' ? sessoes.ociosidade_min : OCIOSIDADE_PADRAO_MIN;
53    const acima = sessoes.janelas
54      .filter((w) => (agora - w.desde) / 60000 > limite)
55      .sort((a, b) => a.desde - b.desde);
56    if (acima.length > 0) {
57      const w = acima[0];
58      parada = {
59        cwd: w.cwd,
60        pasta: pastaDe(w.cwd),
61        min: Math.floor((agora - w.desde) / 60000),
62        outras: acima.length - 1,
63      };
64    }
65  }
66  return { jornada: j, parada };
67}
68
69export function linhaRelogio(r) {
70  if (!r || (!r.jornada && !r.parada)) return null;
71  const partes = [];
72  if (r.jornada) partes.push('jornada ' + hhmm(r.jornada.efetiva_min) + ' · ' + r.jornada.hora);
73  if (r.parada) {
74    const mais = r.parada.outras > 0 ? ' (+' + r.parada.outras + ')' : '';
75    partes.push(r.parada.pasta + ' parada há ' + hhmm(r.parada.min) + mais);
76  }
77  return '⏰ ' + partes.join(' | ');
78}
79
80// Nunca os minutos: so o dia (jornada) e quem esta mais parada e quantas passam do limite.
81export function assinaturaRelogio(r) {
82  if (!r) return '';
83  const trechos = [];
84  if (r.jornada) trechos.push('j:' + r.jornada.dia);
85  if (r.parada) trechos.push('p:' + r.parada.cwd + ':' + (r.parada.outras + 1));
86  return trechos.join('|');
87}
88
89export function notaJornada(r) {
90  if (!r || !r.jornada) return null;
91  return (
92    'Relógio do mod (regra 8): a jornada efetiva de hoje é ' + hhmm(r.jornada.efetiva_min) +
93    ' e são ' + r.jornada.hora + '. Avalie o aviso da regra 8 neste turno: se o usuário está ' +
94    'produzindo ativamente, avise uma única vez (a hora, um ponto de parada concreto, a checagem de corpo); ' +
95    'se está delegando em projeto de descanso, não avise. Esta nota não se repete hoje.'
96  );
97}
98
hooks/abertura-mod-puro.mjs 149 lines
1// Logica do mod da abertura, pura: ES module sem Node. Nao recebe o `$`: o engine nao
2// aceita `$` passado como argumento (so `$.substantivo.evento(...)` no ponto de chamada,
3// medido por `claude plugin validate`). Recebe `io`, tres funcoes que `register.ts` tira
4// do `$`. `register.ts` so liga os tres hooks a ela; a bateria
5// `hooks/testa-mod-abertura.cjs` carrega o proprio register.ts com um `$` falso.
6//
7// O que o mod faz: monta UMA vez por sessao a abertura (foco com as regras + memoria)
8// rodando os dois geradores com `--destino mod`, acrescenta o texto como a secao
9// `rainforest-mind:abertura` do system prompt (sem o teto de entrega do hook
10// SessionStart) e tira do `classic.SessionStart` as entradas dos mesmos dois hooks, para
11// o modelo nao receber a abertura duas vezes. Por que o canal e o prompt, e nao o hook,
12// esta em hooks/abertura-mod.json e nos geradores.
13//
14// CONTA COM sec-default (login org): o built-in `cc-plugin-sec-default` barra, por politica
15// da organizacao, `prompt.compose` e `classic.SessionStart` de plugin tier user. O mod nao
16// tenta entregar a abertura por outro canal: a abertura chega so pelo nucleo do SessionStart
17// que o harness deixar passar. Regras inteiras ali pedem liberacao do admin da organizacao.
18//
19// FALHA ABERTA: se a montagem falha (gerador com exit != 0, JSON invalido, timeout,
20// additionalContext ausente), a secao nao e acrescentada E nenhuma entrada do
21// SessionStart e removida. A sessao nunca fica sem as regras: no pior caso elas chegam
22// pelo canal de sempre.
23
24/**
25 * @typedef {{
26 *   rodar: (argv: string[], init: object) => Promise<any>,
27 *   cwd: () => Promise<string>,
28 *   raiz: string,
29 * }} Io
30 */
31
32export const ID_SECAO = 'rainforest-mind:abertura';
33
34// 60 s: o hook do foco ja levou 6 a 8 s (issue #243); o teto padrao do `$.process.run`
35// e 30 s.
36export const TIMEOUT_MS = 60000;
37
38export const GERADORES = [
39  { nome: 'foco', script: 'foco-session-start.cjs', obrigatorio: true },
40  { nome: 'memoria', script: 'memoria-session-start.cjs', obrigatorio: false },
41];
42
43// Como o additionalContext de cada hook COMECA. Sao os textos reais emitidos hoje
44// (hooks/lib/contexto-sessao.cjs e hooks/lib/memoria-sessao.cjs); a bateria os confere
45// contra a saida real dos geradores. Entrada que nao comeca por um destes fica: o
46// filtro so remove o que reconhece, e o `codex-transfer-session-start.cjs` (que tambem
47// escreve no SessionStart) nunca casa.
48export const PREFIXOS_FOCO = [
49  'RAINFOREST MIND ATIVO',
50  '⚠️ **FALHA AO CARREGAR AS REGRAS',
51  '⚠️ **INJEÇÃO ACIMA DO ORÇAMENTO',
52];
53export const PREFIXOS_MEMORIA = [
54  '## Memória (corpus residentes)',
55  '⚠️ Memória acima do orçamento',
56  '⚠️ Captura da memória parada',
57  '⚠️ Manutenção da memória falhou',
58];
59const PREFIXOS = [...PREFIXOS_FOCO, ...PREFIXOS_MEMORIA];
60
61/** @param {unknown} texto */
62export function ehEntradaDaAbertura(texto) {
63  if (typeof texto !== 'string') return false;
64  const t = texto.trimStart();
65  return PREFIXOS.some(p => t.startsWith(p));
66}
67
68/**
69 * Roda um gerador e devolve o `additionalContext` dele. Lanca em qualquer falha.
70 * @param {Io} io
71 * @param {string} cwd
72 * @param {{ nome: string, script: string, obrigatorio: boolean }} g
73 * @returns {Promise<string>}
74 */
75async function gerar(io, cwd, g) {
76  const script = `${io.raiz}/hooks/${g.script}`;
77  const r = await io.rodar(['node', script, '--destino', 'mod'], {
78    cwd: io.raiz,
79    env: { CLAUDE_PROJECT_DIR: cwd },
80    timeoutMs: TIMEOUT_MS,
81  });
82  if (r.exitCode !== 0) throw new Error(`${g.nome}: exit ${r.exitCode}`);
83  if (r.isStdoutTruncated) throw new Error(`${g.nome}: saida truncada`);
84  const ctx = JSON.parse(r.stdout)?.hookSpecificOutput?.additionalContext;
85  if (typeof ctx !== 'string') throw new Error(`${g.nome}: sem additionalContext`);
86  if (g.obrigatorio && !ctx.trim()) throw new Error(`${g.nome}: additionalContext vazio`);
87  return ctx.trim();
88}
89
90/**
91 * Monta o texto da secao: additionalContext do foco, depois o da memoria, sem os
92 * systemMessage. `null` quando qualquer gerador falha.
93 * @param {Io} io
94 * @returns {Promise<string | null>}
95 */
96async function montar(io) {
97  try {
98    const cwd = await io.cwd();
99    const partes = await Promise.all(GERADORES.map(g => gerar(io, cwd, g)));
100    const texto = partes.filter(Boolean).join('\n\n');
101    return texto || null;
102  } catch {
103    return null;
104  }
105}
106
107/**
108 * Um estado por mod carregado: a promessa da montagem. Memoizar a PROMESSA (e nao o
109 * texto) resolve a ordem dos eventos: o `classic.SessionStart` pode chegar antes do
110 * primeiro `prompt.compose`, e os dois esperam a mesma montagem em vez de rodar os
111 * geradores duas vezes, e o filtro do SessionStart so age sabendo que ela deu certo.
112 */
113export function criarAbertura() {
114  /** @type {Promise<string | null> | null} */
115  let memo = null;
116
117  return {
118    /**
119     * A montagem (uma por abertura).
120     * @param {Io} io
121     */
122    obter(io) {
123      if (memo) return memo;
124      memo = montar(io);
125      return memo;
126    },
127
128    /** `/clear` e `/resume` encerram a conversa sem `session.start`: a proxima abertura e remontada. */
129    aoEncerrar(/** @type {string} */ reason) {
130      if (reason === 'clear' || reason === 'resume') memo = null;
131    },
132
133    /** Resultado do `prompt.compose` com a secao no fim; `r` intacto sem texto. */
134    comSecao(/** @type {any} */ r, /** @type {string | null} */ texto) {
135      if (!texto || !r || !Array.isArray(r.sections)) return r;
136      if (r.sections.some((/** @type {any} */ s) => s.id === ID_SECAO)) return r;
137      return { ...r, sections: [...r.sections, { id: ID_SECAO, text: texto, scope: 'session' }] };
138    },
139
140    /** Resultado do `classic.SessionStart` sem as entradas da abertura; `r` intacto sem texto. */
141    semAbertura(/** @type {any} */ r, /** @type {string | null} */ texto) {
142      if (!texto || !r || !Array.isArray(r.additionalContext)) return r;
143      const ficam = r.additionalContext.filter((/** @type {any} */ x) => !ehEntradaDaAbertura(x));
144      if (ficam.length === r.additionalContext.length) return r;
145      return { ...r, additionalContext: ficam };
146    },
147  };
148}
149
types/index.d.ts 105 lines
1// Contrato de estado do mod (`$.state`, plugin `rainforest-mind`). O engine confere cada
2// chave que o modulo nomeia contra este arquivo. Forma dos dados: scripts/faixa-dados.cjs
3// (faixaDados) e hooks/mod.tsx (painelStats).
4export type RainforestMindFaixaFluxo = {
5  slug: string
6  titulo?: string
7  etapa: string
8  tarefas_ok: number | null
9  tarefas: number | null
10  em_voo: string[]
11  criado_em?: string
12  worktree?: string
13}
14
15export type RainforestMindFaixaDados = {
16  fluxos: RainforestMindFaixaFluxo[]
17}
18
19export type RainforestMindRelogioJornada = { efetiva_min: number; ultimo_ms: number }
20
21export type RainforestMindRelogioSessoes = {
22  ociosidade_min: number
23  janelas: { cwd: string; desde: number }[]
24}
25
26// Uma fatia do contexto, como o `session.usage` a devolve (so as categorias `used`).
27export type RainforestMindPainelFatia = { nome: string; tokens: number; tipo: string }
28
29// Um subagente visto pelo mod. `fimEm` nulo = ainda em andamento (ou sem fim visto).
30export type RainforestMindPainelAgente = {
31  id: string
32  rotulo: string
33  tipo: string
34  modelo: string | null
35  iniciadoEm: number
36  fimEm: number | null
37  vistoEm: number
38  criadoAqui: boolean
39  ferramentas: number
40  tokensLidos: number
41  tokensSaida: number
42  falhou: boolean
43}
44
45// Contadores da sessao que a barra e o pane leem. Tempos em ms de `$.clock.now()`.
46export type RainforestMindPainelStats = {
47  turnos: number
48  ferramentas: number
49  falhas: number
50  tokensNovos: number
51  tokensCacheLido: number
52  tokensCacheEscrito: number
53  tokensSaida: number
54  custoUsd: number | null
55  ctxPct: number | null
56  ctxTokens: number | null
57  ctxJanela: number | null
58  fatias: RainforestMindPainelFatia[]
59  carimbos: number[]
60  agentes: RainforestMindPainelAgente[]
61  ultimaRequisicaoMs: number
62  modelo: string | null
63  ttlMs: number
64  medirDeNovo: boolean
65}
66
67// Mapa da sessao (D13): o que foi escrito (por Edit, Write ou NotebookEdit), que skill rodou, que
68// servico MCP respondeu e que subagente entrou. `desvio` = fora dos `arquivos:` do plano do fluxo.
69export type RainforestMindPainelMapa = {
70  arquivos: { caminho: string; desvio: boolean }[]
71  skills: string[]
72  servicos: string[]
73  subagentes: string[]
74}
75
76// "Deixado para depois" (D7): itens (aberto, enviado pelo Faz agora ou resolvido pelo
77// checker), se o checker esta rodando e ligado, o ultimo pedido
78// da pessoa (ate 4000 caracteres) e as ferramentas do turno da sessao principal (nome e falha).
79export type RainforestMindPainelDeixadoOrigem = 'Claude disse' | 'em arquivo' | 'segundo modelo'
80
81export type RainforestMindPainelDeixado = {
82  itens: { id: number; texto: string; origem: RainforestMindPainelDeixadoOrigem; estado: 'aberto' | 'enviado' | 'resolvido' }[]
83  proximo: number
84  checando: boolean
85  checar: boolean
86  pedido: string
87  ferramentas: { tool: string; deny: boolean; isError: boolean }[]
88}
89
90declare module 'claude-code' {
91  interface PluginState {
92    'rainforest-mind': {
93      faixaDados: RainforestMindFaixaDados | null
94      painelStats: RainforestMindPainelStats
95      painelOculto: boolean
96      painelMapa: RainforestMindPainelMapa
97      painelDeixado: RainforestMindPainelDeixado
98      relogioJornada: RainforestMindRelogioJornada | null
99      relogioSessoes: RainforestMindRelogioSessoes | null
100      relogioNotaPendente: string | null
101      relogioNotaEntregue: string | null
102    }
103  }
104}
105