SLOPSHOPPER

contexto-bar

Barra de contexto acima do prompt, com uma segunda camada de objetivo e pendências compartilhada entre o operador e o Claude

newpanebandguardcommandprompt
v0.21.0MITupdated 2026-10-16aiob3/claude-grounds/contexto-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · contexto-bar
│ ┃ Home ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ AGORA ✔ concluído ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Nenhum objetivo ativo. ⏺ Update(src/auth.ts) │ ┃ /objetivo texto :: passo que conclui ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ CADERNO · o que você pensar durante a ⎿ 3 pass, 1 fail │ ┃ execução │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ tipo [ ● nota ] [ agora ] [ depois ] [ ref │ ┃ │ observação, ideia ou dúvida: o Claude ✻ Worked for 42s · done 4:20 PM │ ┃ │ avalia e responde, sem mudar de rumo │ ┃ │ ✎ escreva aqui; #id no começo liga a uma p › /objetivo │ ┃ │ clique no campo (ou ctrl+x tab) · Enter ⎿ contexto-bar: Painel do objetivo aberto (Esc fecha). │ ┃ │ envia · Esc volta ao prompt │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ auto ligado │ ┃ cache 93% (91k de 97k lidos) · último pedido │ ┃ 97k │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Home
AGORA ✔ concluído Nenhum objetivo ativo. /objetivo texto :: passo que conclui CADERNO · o que você pensar durante a execução ╭─────────────────────────────────────────────────────────── │ tipo [ ● nota ] [ agora ] [ depois ] [ referência ] │ observação, ideia ou dúvida: o Claude avalia e responde, │ sem mudar de rumo │ ✎ escreva aqui; #id no começo liga a uma pendência ⏎ envia │ clique no campo (ou ctrl+x tab) · Enter envia · Esc │ volta ao prompt ╰─────────────────────────────────────────────────────────── auto ligado cache 93% (91k de 97k lidos) · último pedido 97k
Pane · Caderno
CADERNO · o que você pensar durante a execução ╭─────────────────────────────────────────────────────────── │ tipo [ ● nota ] [ agora ] [ depois ] [ referência ] │ observação, ideia ou dúvida: o Claude avalia e responde, │ sem mudar de rumo │ ✎ escreva aqui; #id no começo liga a uma pendência ⏎ envia │ clique no campo (ou ctrl+x tab) · Enter envia · Esc │ volta ao prompt ╰───────────────────────────────────────────────────────────
Pane · Prova
Nenhuma rodada do prova-de-teste nesta sessão.
Pane · Agentes
QUEM EXECUTA ✔ esta sessão
README

Os testes que o agente escreve passam. Mas pegam bug?

O Claude Code escreve o código, escreve os testes, e tudo fica verde. Só que teste verde não quer dizer teste que protege, e muitas vezes você só descobre que ele não conferia nada quando o bug aparece. Em tarefa longa ainda há outro problema: o combinado se perde quando a sessão compacta ou recomeça.

O claude-grounds é para quem usa o Claude Code para escrever código com testes, em tarefas que atravessam sessões. São dois mods do Claude Code, o recurso que chegou na versão 2.1.287 para rodar código dentro dele. Instalados juntos, formam uma cadeia. A faixa deixa à vista a carga de contexto e o objetivo do projeto, que passa de uma sessão para a outra. O objetivo pede a prova. A prova é feita por um modelo independente, que vê só o diff: ele planta bugs no código que o agente mudou, e a tarefa só fecha quando os testes pegam. O registro guarda cada rodada e recusa número inflado. Não há settings hook para configurar nem gate travando o agente.

Construindo os próprios mods, a prova achou 187 falhas reais de teste. Em três projetos privados, sem relação com estes, achou mais 1. Num projeto com 8881 testes, provar uma mudança leva uns 2 minutos e meio, 2,8 vezes menos que rodar a suíte completa para cada bug plantado, com os mesmos vereditos.

O que você ganha

A faixa: o contexto do projeto à vista

Acima do prompt, o contexto-bar mostra quanto da janela de contexto está ocupado e por quê (sistema, memória, MCP, skills, hooks, conversa), e se o que falta fazer cabe antes da compactação. Embaixo vem o objetivo do projeto, com as pendências por dono e a barra de etapas. O agente recebe esse objetivo a cada pedido, então a prova pendente não sai da frente dele. O objetivo fica num arquivo por projeto: fechar a sessão, reiniciar a máquina ou abrir outra sessão em paralelo não apaga nada. Na volta, ele está lá.

Isso também é economia. Nas sessões em que estes mods foram construídos, uma sessão longa chegou a pedidos de 338 mil a 701 mil tokens, com 99% de acerto no cache. Uma sessão nova começou com 42 mil a 53 mil tokens, o objetivo de volta na faixa e, nos cinco primeiros pedidos, de 78% a 89% de acerto. Fechar uma sessão pesada e abrir outra deixa cada pedido de 7 a 14 vezes menor, sem perder o fio. A faixa mostra o acerto do cache da sessão no fim da linha, e o painel, o tamanho do último pedido.

A prova: o critério que se reaplica sozinho

O objetivo nasce com a prova dentro, e é aí que o prova-de-teste entra (na tela acima, logo depois de uma pendência concluída). Ele copia o repositório para uma pasta temporária, pede a um modelo sem ferramentas e sem histórico bugs plausíveis para o código que mudou, roda os testes contra cada um e entrega o relatório. Esse modelo vê só o diff, nunca os testes, então o agente não tem como escolher os bugs fáceis. Teste que deixa um bug passar é teste fraco, e a pendência não fecha até ele ser reforçado.

Até aqui, um critério assim ficava escrito num arquivo de instruções, e ninguém garantia que o agente o seguiria nem que ele sobreviveria à próxima sessão. Aqui ele roda sozinho, do mesmo jeito em toda tarefa, e o agente sabe de antemão o que vai ser cobrado.

O registro: nada se perde, nada infla

Cada rodada vira uma linha no log. O registrar só aceita os números da última execução e recusa estado melhor que o executado sem justificativa escrita. O objetivo só fecha quando a prova está registrada, e a próxima sessão, outro dia ou outro projeto retoma dali.

O que ele não faz: não bloqueia ações destrutivas (isso é segurança, outra categoria) e não garante que o código está certo. Garante que os testes pegam os bugs plantados.

Do lado do agente

Pedi ao agente que construiu e usou os mods uma avaliação do próprio trabalho, sem enfeite. Os números saem do log.

  • Ele deixou de decidir sozinho quando um teste está bom. Nas rodadas das versões 0.7.0 a 0.8.1 apareceram 13 falhas reais nos testes dos próprios mods, e mais uma no projeto grande. Todos os testes estavam verdes antes, e sem a rodada teriam sido entregues como "testado".
  • O critério vale mesmo quando ele esquece. As regras estão no registrar, não na memória do agente. Um estado melhor que o executado é recusado.
  • O fio não se perdeu nem com a máquina reiniciada. A sessão caiu num reboot e depois foi compactada. Ela voltou sabendo o objetivo, o que estava feito e de quem era cada pendência, porque isso estava no arquivo do objetivo, e não só no resumo da conversa.
  • O instrumento pegou erros dele mesmo. A confirmação na suíte completa mostrou que três "sobreviventes" eram falsos.
  • Onde não ajudou: na mensagem. Os erros de comunicação do mesmo dia (texto sem contexto para quem chega, termo errado, um nome privado publicado) foram pegos pelo operador, não pelos mods. A prova é mecânica para código e não cobria o texto. Por isso nasceu o EXP-002: o README agora passa por checagens automáticas e por um leitor frio, um modelo que só vê a página e precisa dizer para quem ela é, que dor resolve e o que se ganha.

Instalar

claude plugin marketplace add aiob3/claude-grounds
claude plugin install contexto-bar@claude-grounds
claude plugin install prova-de-teste@claude-grounds
ModVersãoPapel
contexto-bar0.21.0faixa de contexto, objetivo por projeto, painel e projeção da janela
prova-de-teste0.16.0rodada de mutantes às cegas numa cópia isolada, relatório e log

O log do prova-de-teste vai para PROVA_DE_TESTE_LOG ou, sem essa variável, para ~/.claude/prova-de-teste/EXP-001-log.md. O agente usa as ferramentas objetivo, executar e registrar; você usa o /objetivo:

Você digitaO que acontece
/objetivoabre o painel
/objetivo Migrar o login :: testes verdes em produçãodefine o objetivo e o passo que o encerra
/objetivo feito 2 3conclui as pendências 2 e 3
/objetivo bloqueante 4sobe a pendência 4 para o topo da fila
/objetivo meu revisar o contratoacrescenta uma pendência sua
/objetivo encerrarencerra o objetivo do projeto

Primeiros cinco minutos

git clone https://github.com/aiob3/claude-grounds-demo && cd claude-grounds-demo
claude

Cole o pedido da gravação: "Bug: mediana([10, 9, 2]) devolve 2, mas o certo é 9. Corrija em src/estatistica.js. Antes de mexer, defina o objetivo da tarefa. Escreva o teste que pega o bug e mostre ele falhando antes da correção (red first). Depois da correção, prove o teste com a ferramenta executar do prova-de-teste e registre a rodada."

Você deve ver a faixa com o objetivo, o teste falhando, o relatório do executar e o painel no fim. Feche a sessão, abra outra na mesma pasta e digite /objetivo: o objetivo continua lá.

O ciclo, numa sessão real

O pedido era corrigir mediana([10, 9, 2]), que devolvia 2 em vez de 9 porque sort() ordenava os números como texto. O projeto está em claude-grounds-demo para quem quiser repetir.

1. Declarar

O objetivo nasce com a prova dentro dele: ler o código, mostrar o teste falhando, corrigir, provar com o executar, registrar a rodada. A conclusão declarada não é "corrigi". É "a rodada de prova registrada com os números do relatório". Cada pendência tem dono, tamanho e prioridade. Ao declarar, o agente recebe uma checagem de alinhamento: a conclusão descreve o efeito que se quer, e não só um artefato? Cada pendência aproxima dela? E a cada pedido a linha do objetivo pergunta se a próxima ação serve à conclusão.

2. Acompanhar

A cada pedido, o agente recebe uma linha com o objetivo e o que falta, então a prova pendente fica na frente dele até o fim. A faixa divide a janela de contexto por origem, mostra o progresso (0/5, 1/5, 3/5, 5/5 nos recortes acima) e projeta se o restante cabe antes da compactação. A projeção aprende com a sessão: começa no padrão de 6k tokens por item (o asterisco) e passa à média medida, 3,4k. Se não couber, o agente é avisado antes.

3. Provar

<table> <tr> <td width="50%"><img src="assets/demo/02.webp" alt="Red first: o teste falha antes da correção"></td> <td width="50%"><img src="assets/demo/04.webp" alt="FRACO: um sobrevivente real, o teste é reforçado"></td> </tr> </table>

Primeiro o red first: o teste novo falha antes da correção, 2 em vez de 9. Depois da correção, o executar copia o repositório para uma pasta temporária, pede mutantes a um modelo sem ferramentas e sem histórico, que só vê o diff do código, e roda os testes contra cada um. Deu FRACO: um mutante sobreviveu porque nenhum teste via o primeiro elemento da lista. O teste foi reforçado e a mesma lista rodou de novo. Enquanto isso, a pendência "provar" seguia aberta na faixa.

4. Registrar e fechar

<table> <tr> <td width="56%"><img src="assets/demo/05.webp" alt="O agente registra alegando um equivalente"></td> <td><img src="assets/demo/painel.webp" alt="Painel do objetivo em 5 de 5"></td> </tr> </table>

O registrar só aceita os números da última execução. A demo mostrou o que faltava: o agente registrou PROVADO-PARCIAL alegando que um sobrevivente era equivalente, e não era (com lista par, mediana([1,2,3,4]) dá 3 e o mutante dá 2). O log ganhou uma linha de correção, e o registrar passou a exigir a justificativa escrita de cada sobrevivente, gravada no log. O objetivo chega a 5/5 quando a conclusão declarada se cumpre.

5. Continuar

O objetivo fica num arquivo por projeto, e a prova fica no log. Fechar a sessão, reiniciar a máquina ou abrir outra sessão em paralelo não apaga nada. Um trecho do arquivo que guardou o objetivo da sessão acima:

{
  "raiz": "~/Work/claude-grounds-demo",
  "objetivo": {
    "titulo": "Corrigir mediana([10, 9, 2]) → 9",
    "itens": [
      { "id": 2, "dono": "claude", "tamanho": "P", "nivel": "bloqueante", "feito": true },
      { "id": 4, "dono": "claude", "tamanho": "M", "nivel": "requisito", "feito": true }
    ],
    "consumo": [7545, 1335, 1232, 1369]
  }
}

Por que os dois juntos

Um objetivo sem prova é uma lista que o agente marca como feita sozinho. Uma prova sem objetivo é um relatório que ninguém pediu e que se perde quando a sessão acaba. Juntos, a afirmação de "pronto" vem declarada antes, fica à vista durante o trabalho e só fecha com fundamento registrado. Os mods não chamam um ao outro: o elo é o próprio fluxo do agente, que recebe o objetivo a cada pedido e registra a prova no fim.

Construído com ele mesmo

Os dois mods foram escritos sob o próprio método: cada versão passou por uma rodada de mutantes escolhidos às cegas antes de ser publicada, e o instrumento achou defeitos em si mesmo pelo caminho. Um mutante que não carregava contado como morto, um caminho devolvido pelo modelo que fazia 18 mutantes não serem aplicados, um helper de teste tomado por teste. A demo gravada acrescentou o último da lista: o registrar aceitava um estado melhor que o executado sem justificativa.

As garantias de honestidade são mecânicas, não promessas:

  • O log só cresce. Uma correção entra como linha nova e a original fica onde está.
  • O registrar só aceita os números da última execução. Numa sessão real, ele recusou um registro de 13 mutantes mortos quando a execução tinha matado 10.
  • Calibração não conta como medição. Rodada que acha defeito do instrumento, ou que o altera, fica fora da conta que decide se o método será promovido (critérios e estado atual).
  • Os números deste README saem do log a cada publicação, por um gerador que barra termos privados.

Sem hooks para configurar

Sem settings hooks para configurar e sem gate que trave o agente. A etapa que o agente costuma pular, provar o que fez, até agora só era garantida com um gate num hook, em algum ponto do processo, travando o agente ali. Aqui ela entra no próprio ciclo: é declarada no objetivo, volta à frente do agente a cada pedido e é a condição para fechar. Não some, e nada trava. (Impedir ações destrutivas é outro problema, de segurança, e não é o que estes mods fazem.)

Os settings hooks do Claude Code são comandos de shell, chamadas HTTP ou prompts que você liga a eventos no settings.json. Funcionam bem para bloquear ou anotar uma ação, mas rodam fora do Claude Code: não desenham na tela, não guardam estado próprio e não dão ao agente uma ferramenta para chamar. Um mod roda dentro dele, e os dois mods trazem tudo isso no pacote (a documentação oficial tem a comparação completa):

Settings hooks (settings.json)Os mods do claude-grounds
Instalareditar o arquivo e escrever os scriptsclaude plugin install
O agente pode chamarnadaobjetivo, executar, registrar, mutantes
Estado e memóriao script guarda onde conseguirobjetivo por projeto, última execução, log
Na telatexto no terminalfaixa acima do prompt e painel lateral
Verificar o comportamentoà mãoclaude plugin test em cada versão (CI neste repositório)

Nenhum hook dos dois mods segura uma chamada. Os handlers só observam e acrescentam uma linha de contexto, e o rigor está em outro lugar: nas ferramentas que o agente chama porque declarou que chamaria, e num registro que recusa número inflado e equivalente sem justificativa. O agente trabalha livre, e o "pronto" sem fundamento não passa.

Quanto custa provar

A dúvida. Mutation testing tem fama de lento. Cada mutante é um bug plantado de propósito, e para saber se os testes o pegam é preciso rodar a suíte de novo: com 10 mutantes, a suíte roda 11 vezes. Na demo isso leva segundos. Num projeto grande leva minutos, e aí ninguém prova a cada tarefa do agente. A prova vai para o CI noturno, ou para um gate num hook que revalida tudo a cada vez, e o "testado" do agente volta a ficar sem conferência no dia a dia.

O teste. A mesma rodada, num projeto real grande, de dois jeitos. Primeiro com a suíte completa para cada mutante. Depois rodando, por mutante, só os testes afetados: os arquivos de teste que chegam ao arquivo mutado pelo grafo de imports. É test impact analysis, a mesma ideia do vitest related e do jest --findRelatedTests, e o prova-de-teste faz isso desde a versão 0.8. O projeto tem 3929 arquivos e 8881 testes no recorte verde da suíte (vitest). A mudança provada tem 10 linhas. Foram os mesmos 10 mutantes, na mesma máquina.

O resultado.

rodadapor mutantetestes por mutantevereditos
suíte completa em cada mutante407 s35,9 s88818 mortos, 2 sobreviventes
só os testes afetados145 s10,0 s1906os mesmos
idem, com o teste reforçado107 s6,4 s10189 mortos, 1 equivalente

A rodada ficou 2,8 vezes mais rápida, de quase 7 minutos para 2 e meio, com exatamente os mesmos vereditos. Um mutante morto passou de uns 36 s para uns 3 s, porque rodou 12 arquivos de teste em vez de 1007.

O que isso prova. Num projeto desse tamanho, conferir se os testes de uma mudança pegam bug cabe em cada tarefa do agente, sem esperar o CI. O custo de cada mutante morto passou a depender do tamanho da mudança, não do tamanho do projeto, e a economia não trocou nenhum veredito.

Um segundo caso, que qualquer um pode repetir. No rich, com 553 arquivos e 944 testes no recorte verde (pytest), uma mudança de 12 linhas em rich/tree.py gerou 6 mutantes. Com a suíte completa em cada mutante, a rodada levou 43 s. Só com os testes afetados, 24 s, com os mesmos vereditos: cada mutante rodou 2 dos 63 arquivos de teste. O ganho é menor porque a suíte é rápida (4,8 s) e metade dos mutantes sobreviveu, e cada sobrevivente é confirmado na suíte completa. Esses 3 sobreviventes são lacunas reais nos testes do próprio rich: nenhum teste percebe quando um nó filho herda errado o highlight ou o guide_style do pai.

O que não prova. Foram dois projetos, dois executores de testes e mudanças pequenas. Em projetos com outro desenho de imports o ganho pode ser maior ou menor. Medir mais casos está entre os pontos em aberto, no fim deste README.

Por que não fica ainda mais rápido. Duas etapas continuam rodando a suíte completa, de propósito:

  • A linha de base, uma vez por rodada, para pegar regressão no código antigo.
  • A confirmação de cada sobrevivente. O grafo de imports só decide a velocidade, nunca o veredito: uma morte só vale se um teste nomeado falhou, e um mutante só é dado como vivo depois de passar pela suíte completa. Nos próprios mods, essa confirmação pegou três mutantes que sobreviveram aos testes afetados e morreram na suíte completa, porque um teste chegava ao código pelo runtime do mod, sem import.

Por isso, quanto melhores os testes, mais rápida a rodada: com menos sobreviventes, sobra a linha de base mais uns 3 s por mutante. O relatório e o log trazem esses números a cada rodada (custo: base, por mutante, repositório, diff), medidos pelo próprio mod.

Limites e próximos passos

  • O prova-de-teste acha o comando de teste por convenção (claude plugin test, npm test, pytest). Para outro executor, passe comando ao executar.
  • A seleção dos testes afetados segue imports estáticos: JS/TS (com os aliases do tsconfig e do vite) e Python. O que chega ao código por outro caminho (runtime, leitura de arquivo, subprocesso) fica fora do mapa. Por isso todo sobrevivente é confirmado na suíte completa e a linha de base roda completa. incremental: false desliga.
  • A equivalência de um sobrevivente continua sendo julgada pelo agente. O mod exige a justificativa e a grava no log, para auditoria, mas não a prova.
  • A projeção da janela é uma estimativa: a média das últimas 8 entregas por unidade de tamanho.
  • Os mods exigem o Claude Code 2.1.287 ou mais recente (a versão em que os mods chegaram) e foram testados na 2.1.300. Funcionam no terminal e na aba Code do app Desktop.

Em aberto: os critérios de saída estão cumpridos (43 medições, instrumento estável); a decisão de promover é do operador; medir o ganho dos testes afetados em mais projetos e linguagens; guardar o histórico de consumo por projeto, independente do objetivo; observar se a média por unidade se estabiliza entre sessões.

Avaliar

Critérios e estado · Log das rodadas · Diário · Protocolo

No modelo de argumentação de Toulmin, grounds é a evidência que sustenta uma afirmação:

ToulminAqui
claim"a tarefa está feita", declarada no objetivo (contexto-bar)
groundsred first e o relatório com cada mutante (prova-de-teste)
rebuttalmutantes escolhidos às cegas por um modelo que não viu os testes
qualifiero estado: PROVADO, PROVADO-PARCIAL, FRACO ou NÃO VERIFICADO
  1. Red first: o teste novo falha antes da correção, ou com a linha central da implementação quebrada.
  2. Pelo menos um mutante em cada categoria que se aplique ao diff (borda ou vazio, empate ou ordem, null ou inválido, condição invertida), aplicados um por vez, com a restauração conferida.
  3. Os mutantes vêm de um modelo sem ferramentas e sem histórico, que recebe só o diff do código-fonte.
  4. Cada sobrevivente é reforçado até morrer, ou justificado em uma linha como equivalente. Fora do escopo não é equivalência.
  5. O relatório lista cada mutante e termina com o estado.
  • $.model.complete: um modelo isolado, sem ferramentas e sem o histórico da sessão, garante a escolha às cegas.
  • $.tool.register: as ferramentas objetivo, executar, registrar e mutantes.
  • $.ui: a faixa acima do prompt e o painel lateral.
  • Eventos prompt.submit, tool.call, turn.complete e session.compact: a linha do objetivo a cada pedido, a faixa atualizada depois de cada ferramenta e a estimativa refeita depois da compactação.
  • $.fs: o objetivo de cada projeto num arquivo próprio.
  • claude plugin test e claude plugin validate em cada versão e no CI.

Projeto independente e não oficial, sem afiliação com a Anthropic nem endosso dela. Licença MIT. Referências: "Mutation testing is AWESOME" (Matt Pocock); Practical Mutation Testing at Scale (Google); Mutation-Guided LLM-based Test Generation at Meta (arXiv 2501.12862).

Source 9 files
hooks/register.tsx 1594 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { AoVivoProva, VizinhoHerdr } from '../types'
5import type { Atividade, Decisao, EventoRoadmap, Executores, Goal, GoalItem, GroupKey, Horizonte, Indicador, ItemRoadmap, Nivel, Nota, Owner, Segment, Size, Snapshot, TipoNota } from '../types'
6import { alinhamento, aoCarregar, aplicar, aposCompactar, avisoJanela, bloqueioDeLicenca, comando, textoDisparo, etiqueta, linhaContexto, notaPublicacao, ordenar, projetar, proxima, resumo, usado } from './objetivo'
7import type { Acao } from './objetivo'
8import { CACHE_VAZIO, caminhoTranscript, doTranscript, somar, taxa } from './cache'
9import { alertas, avisoAoModelo, caminhoSaida, concluirTarefa, FERRAMENTAS_DE_VOLTA, fimDeTurno, linhasDoPainel, notificacao, registrarTarefa, SP_VAZIO } from './segundo-plano'
10import type { SegundoPlano } from './segundo-plano'
11import { DECISOES, entrega, GLIFO_DECISAO, idade, lembrete, paraLembrar, lerCaderno, linha as linhaCaderno, novaNota, pendenciaDoCaderno, PROPOSITO, semResposta, TIPOS, triagem } from './caderno'
12import { abertos, destinoRoadmap, doHorizonte, ehHorizonte, faixaRoadmap, HORIZONTES, lembreteRoadmap, lerRoadmap, linha as linhaRoadmap, novoItem, resumoRoadmap, VERSAO_ROADMAP } from './roadmap'
13import { centralizar, centroIndicador, COR_INDICADOR, encurtar, estadoDoHerdr, ESTADOS, faixaVizinhos, nomeModelo, textoAtividade, validarAoVivo, linhaAoVivo, validarIndicadores, validarVizinhos } from './painel'
14import { estiloDoTerminal, gauge, LARGURA_GAUGE, LINHAS_GAUGE, pedacos } from './gauge'
15import type { EstiloGauge } from './gauge'
16
17const snapshot = atom({ plugin: 'contexto-bar', key: 'snapshot' } as const, null)
18const objetivo = atom({ plugin: 'contexto-bar', key: 'objetivo' } as const, null)
19const turno = atom({ plugin: 'contexto-bar', key: 'turno' } as const, 0)
20const cache = atom({ plugin: 'contexto-bar', key: 'cache' } as const, null)
21const pedido = atom({ plugin: 'contexto-bar', key: 'pedido' } as const, null)
22const segundoPlano = atom({ plugin: 'contexto-bar', key: 'segundoPlano' } as const, null)
23const atividade = atom({ plugin: 'contexto-bar', key: 'atividade' } as const, null)
24const verFeitas = atom({ plugin: 'contexto-bar', key: 'verFeitas' } as const, false)
25const indicadores = atom({ plugin: 'contexto-bar', key: 'indicadores' } as const, [])
26const autoEspelho = atom({ plugin: 'contexto-bar', key: 'painelAuto' } as const, null)
27const caderno = atom({ plugin: 'contexto-bar', key: 'caderno' } as const, [])
28const entregues = atom({ plugin: 'contexto-bar', key: 'entregues' } as const, [])
29const tipoNota = atom({ plugin: 'contexto-bar', key: 'tipoNota' } as const, 'nota')
30const envios = atom({ plugin: 'contexto-bar', key: 'envios' } as const, 0)
31const executores = atom({ plugin: 'contexto-bar', key: 'executores' } as const, { subagentes: [] })
32const rascunho = atom({ plugin: 'contexto-bar', key: 'rascunho' } as const, '')
33const passos = atom({ plugin: 'contexto-bar', key: 'passos' } as const, 0)
34const ultimaEntrega = atom({ plugin: 'contexto-bar', key: 'ultimaEntrega' } as const, {})
35const roadmap = atom({ plugin: 'contexto-bar', key: 'roadmap' } as const, [])
36const vizinhos = atom({ plugin: 'contexto-bar', key: 'vizinhos' } as const, [])
37const aoVivo = atom({ plugin: 'contexto-bar', key: 'aoVivo' } as const, null)
38const verRoadmap = atom({ plugin: 'contexto-bar', key: 'verRoadmap' } as const, false)
39
40export const TOOL_NAME = 'objetivo'
41export const TOOL = `mcp__contexto-bar__${TOOL_NAME}`
42const PANE = 'objetivo'
43// As abas do painel: cada uma é um painel nativo; com mais de um aberto, a superfície desenha os títulos como abas.
44export const ABAS = [
45  { id: 'objetivo', titulo: 'Home' },
46  { id: 'caderno', titulo: 'Caderno' },
47  { id: 'prova', titulo: 'Prova' },
48  { id: 'agentes', titulo: 'Agentes' },
49] as const
50type Aba = (typeof ABAS)[number]['id']
51
52const TOOL_SPEC = {
53  name: TOOL_NAME,
54  description:
55    'Objetivo da tarefa em curso, compartilhado com o operador numa barra acima do prompt. Use ao começar um pedido com etapas ' +
56    '(definir: titulo, conclusao = o passo que encerra, itens com dono claude|operador, tamanho P|M|G e nivel ' +
57    'bloqueante|requisito|melhoria), ao concluir cada pendência (concluir: ids), para repriorizar (priorizar: ids + nivel), ' +
58    'acrescentar (adicionar), reabrir, encerrar ou ver. Com duas ou mais frentes suas, declare como cada uma roda (disparo: ids, modo, meio, harness). Bloqueante trava outra pendência ou a entrega; requisito é preciso ' +
59    'para a entrega; melhoria pode esperar. A resposta traz a fila na ordem e a projeção de janela. ' +
60    'Notas do caderno do operador chegam com o resultado de outras ferramentas: responda a cada uma com triar ' +
61    '(nota, decisao, motivo, destino, contraponto).',
62  isDeferred: false,
63  inputSchema: {
64    type: 'object',
65    properties: {
66      acao: { type: 'string', enum: ['definir', 'adicionar', 'concluir', 'reabrir', 'priorizar', 'encerrar', 'ver', 'triar', 'disparo'] },
67      titulo: { type: 'string' },
68      conclusao: { type: 'string', description: 'O passo que, feito, encerra o objetivo.' },
69      itens: {
70        type: 'array',
71        items: {
72          type: 'object',
73          properties: {
74            texto: { type: 'string' },
75            dono: { type: 'string', enum: ['claude', 'operador'] },
76            tamanho: { type: 'string', enum: ['P', 'M', 'G'] },
77            nivel: { type: 'string', enum: ['bloqueante', 'requisito', 'melhoria'] },
78          },
79          required: ['texto'],
80        },
81      },
82      ids: { type: 'array', items: { type: 'integer' } },
83      nivel: { type: 'string', enum: ['bloqueante', 'requisito', 'melhoria'], description: 'Para priorizar.' },
84      modo: { type: 'string', enum: ['paralelo', 'fila'], description: 'Para disparo: a frente roda em paralelo com as outras ou em fila.' },
85      meio: { type: 'string', enum: ['sessao', 'subagente', 'workflow', 'herdr'], description: 'Para disparo: onde a frente roda (esta sessão, um subagente, um workflow ou um pane do herdr).' },
86      harness: { type: 'string', description: 'Para disparo: o agente de linha de comando que executa (claude, codex…).' },
87      nota: { type: 'string', description: 'Para triar: o id da nota do caderno (n-...).' },
88      decisao: { type: 'string', enum: ['aceita', 'recusada', 'muda-plano', 'registrada'], description: 'Para triar.' },
89      motivo: { type: 'string', description: 'Para triar: o porquê, em uma frase.' },
90      destino: { type: 'string', description: 'Para triar: para onde a nota foi (#id da pendência, roadmap).' },
91      contraponto: { type: 'string', description: 'Para triar: o melhor argumento contra a sua decisão, em uma frase.' },
92    },
93    required: ['acao'],
94  },
95} as const
96
97export const ROADMAP_TOOL_NAME = 'roadmap'
98export const ROADMAP_TOOL = `mcp__contexto-bar__${ROADMAP_TOOL_NAME}`
99
100const ROADMAP_SPEC = {
101  name: ROADMAP_TOOL_NAME,
102  description:
103    'Roadmap do projeto: o plano aprovado além do objetivo em curso, que sobrevive ao encerrar. Horizonte agora (entra ' +
104    'no próximo objetivo), proximo ou depois; frente agrupa um assunto (herdr, painel). Ações: ver; adicionar (itens com ' +
105    'texto, horizonte, frente); mover (ids R, horizonte); promover (ids R: viram pendências do objetivo ativo, com nivel e ' +
106    'tamanho); adiar (pendencias: ids do objetivo que saem dele e voltam ao roadmap); descartar (ids R, motivo); aprovar ' +
107    '(ids R + citacao: a frase do operador que aprovou, copiada). Plano que o operador aprovou vem para cá, não fica só na ' +
108    'conversa. Encerrar o objetivo devolve as pendências abertas para agora. O item que você adiciona é PROPOSTA: só ' +
109    'promove depois de o operador aprovar (botão no painel, ou a frase dele em aprovar).',
110  isDeferred: false,
111  inputSchema: {
112    type: 'object',
113    properties: {
114      acao: { type: 'string', enum: ['ver', 'adicionar', 'mover', 'promover', 'adiar', 'descartar', 'aprovar'] },
115      itens: {
116        type: 'array',
117        items: {
118          type: 'object',
119          properties: {
120            texto: { type: 'string' },
121            horizonte: { type: 'string', enum: ['agora', 'proximo', 'depois'] },
122            frente: { type: 'string', description: 'Assunto que agrupa itens (herdr, painel, prova).' },
123          },
124          required: ['texto'],
125        },
126      },
127      ids: { type: 'array', items: { type: 'integer' }, description: 'Números R dos itens do roadmap.' },
128      pendencias: { type: 'array', items: { type: 'integer' }, description: 'Para adiar: ids das pendências do objetivo.' },
129      horizonte: { type: 'string', enum: ['agora', 'proximo', 'depois'], description: 'Para mover e adiar (padrão agora).' },
130      frente: { type: 'string', description: 'Para adiar: a frente dos itens que entram.' },
131      nivel: { type: 'string', enum: ['bloqueante', 'requisito', 'melhoria'], description: 'Para promover (padrão requisito).' },
132      tamanho: { type: 'string', enum: ['P', 'M', 'G'], description: 'Para promover (padrão M).' },
133      dono: { type: 'string', enum: ['claude', 'operador'], description: 'Para promover (padrão claude).' },
134      motivo: { type: 'string', description: 'Para descartar: o porquê, em uma frase.' },
135      citacao: { type: 'string', description: 'Para aprovar: a frase do operador que aprova, copiada da mensagem dele.' },
136    },
137    required: ['acao'],
138  },
139} as const
140
141// Ordem na barra e na legenda; cada grupo tem uma cor fixa.
142export const GROUPS: { key: GroupKey; label: string; color: string; char: string }[] = [
143  { key: 'sistema', label: 'Sistema Claude Code', color: '#3b82f6', char: '█' }, // azul
144  { key: 'claudemd', label: 'CLAUDE.md + memória', color: '#eab308', char: '█' }, // amarelo
145  { key: 'mcp', label: 'MCP', color: '#a855f7', char: '█' }, // roxo
146  { key: 'skills', label: 'Skills', color: '#22c55e', char: '█' }, // verde
147  { key: 'hooks', label: 'Hooks', color: '#ef4444', char: '█' }, // vermelho
148  { key: 'conversa', label: 'Conversa', color: '#06b6d4', char: '█' }, // ciano
149  { key: 'livre', label: 'Livre', color: '#6b7280', char: '░' }, // cinza
150  { key: 'reserva', label: 'Reserva (compacta aqui)', color: '#f97316', char: '▒' }, // laranja
151]
152
153/** Classifica uma linha do /context num grupo da barra. */
154export function groupOf(name: string, kind: string): GroupKey | null {
155  if (kind === 'deferred') return null
156  if (kind === 'free') return 'livre'
157  if (kind === 'buffer') return 'reserva'
158  const n = name.toLowerCase()
159  if (n.includes('mcp')) return 'mcp'
160  if (n.includes('skill')) return 'skills'
161  if (n.includes('memory')) return 'claudemd'
162  if (n.includes('message')) return 'conversa'
163  return 'sistema'
164}
165
166export function buildSnapshot(
167  categories: readonly { name: string; tokens: number; kind: string }[],
168  hookTokens: number,
169): Snapshot {
170  const sums: Record<GroupKey, number> = {
171    sistema: 0, claudemd: 0, mcp: 0, skills: 0, hooks: 0, conversa: 0, livre: 0, reserva: 0,
172  }
173  for (const c of categories) {
174    const g = groupOf(c.name, c.kind)
175    if (g) sums[g] += c.tokens
176  }
177  // O texto dos hooks chega dentro das mensagens: tira de lá para não contar duas vezes.
178  const hooks = Math.min(hookTokens, sums.conversa)
179  sums.hooks = hooks
180  sums.conversa -= hooks
181
182  const segments: Segment[] = GROUPS.map(g => ({ key: g.key, tokens: sums[g.key] }))
183  const total = segments.reduce((a, s) => a + s.tokens, 0)
184  const used = total - sums.livre - sums.reserva
185  return { percent: total > 0 ? Math.round((used / total) * 100) : 0, total, segments }
186}
187
188/** Quantas células cada segmento ocupa numa barra de `width` células. */
189export function cellsFor(snap: Snapshot, width: number): number[] {
190  const cells = snap.segments.map(s => {
191    if (s.tokens <= 0 || snap.total <= 0) return 0
192    return Math.max(1, Math.round((s.tokens / snap.total) * width))
193  })
194  // Ajusta o arredondamento no espaço livre (ou no maior segmento, se não houver livre).
195  const diff = width - cells.reduce((a, b) => a + b, 0)
196  const freeIdx = snap.segments.findIndex(s => s.key === 'livre')
197  const fixIdx = (cells[freeIdx] ?? 0) > 0 ? freeIdx : cells.indexOf(Math.max(...cells))
198  cells[fixIdx] = Math.max(0, (cells[fixIdx] ?? 0) + diff)
199  return cells
200}
201
202export function fmtTokens(n: number): string {
203  if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`
204  return String(n)
205}
206
207// Lê o detalhamento do /context (estimativa local, sem custo) e guarda para a barra.
208async function refresh($: EngineInterface, hookTexts: Set<string>) {
209  await lerIndicadores($).catch(() => undefined)
210  await lerVizinhos($).catch(() => undefined)
211  await lerAoVivo($).catch(() => undefined)
212  await lerDoCaderno($).catch(() => undefined)
213  try {
214    const usage = await $.session.usage({ breakdown: 'summary' })
215    // O tamanho do último pedido vem pronto: entrada sem cache, lida e gravada no cache, somadas.
216    if (usage.context.tokens) await update($, pedido, () => usage.context.tokens ?? null)
217    const b = usage.context.breakdown
218    if (!b) return
219    let hookChars = 0
220    for (const t of hookTexts) hookChars += t.length
221    const snap = buildSnapshot(b.categories, Math.round(hookChars / 4))
222    await update($, snapshot, () => snap)
223  } catch {
224    // Sem sessão ligada ainda: tenta de novo no próximo turno.
225  }
226}
227
228export const INTERVALO_LEITURA_MS = 2000
229let timerLeitura: Timer | undefined
230
231/** A rodada do prova-de-teste nesta sessão, do arquivo que o executar escreve enquanto roda (contrato no prova-de-teste). */
232async function lerAoVivo($: EngineInterface): Promise<void> {
233  const [home, id] = await Promise.all([$.env.get('HOME'), $.session.id()])
234  const arquivo = home && id ? `${home}/.claude/prova-de-teste/ao-vivo/${encodeURIComponent(id)}.json` : undefined
235  if (!arquivo || !(await $.fs.exists(arquivo).catch(() => false))) return
236  let lido: AoVivoProva | null
237  try {
238    lido = validarAoVivo(JSON.parse(await $.fs.read(arquivo)))
239  } catch {
240    return
241  }
242  if (JSON.stringify(await read($, aoVivo)) !== JSON.stringify(lido)) await update($, aoVivo, () => lido)
243}
244
245/** Os outros workspaces do herdr (contrato em painel.ts), do arquivo deste pane; fora do herdr, nenhum. */
246async function lerVizinhos($: EngineInterface): Promise<void> {
247  const [home, pane] = await Promise.all([$.env.get('HOME'), $.env.get('HERDR_PANE_ID')])
248  const arquivo = home && pane ? `${home}/.claude/herdr/vizinhos/${encodeURIComponent(pane)}.json` : undefined
249  let lidos: VizinhoHerdr[] = []
250  if (arquivo && (await $.fs.exists(arquivo).catch(() => false))) {
251    try {
252      lidos = validarVizinhos(JSON.parse(await $.fs.read(arquivo)))
253    } catch {
254      // Arquivo pela metade (o outro mod escrevendo): fica o que estava até a próxima leitura.
255      return
256    }
257  }
258  if (JSON.stringify(await read($, vizinhos)) !== JSON.stringify(lidos)) await update($, vizinhos, () => lidos)
259}
260
261/**
262 * Lê os indicadores (o contrato em painel.ts): os globais em ~/.claude/indicadores/*.json e os do projeto da sessão
263 * em ~/.claude/indicadores/<raiz>/*.json. Os de outra raiz nunca entram: cada sessão vê os números do seu projeto.
264 */
265async function lerIndicadores($: EngineInterface): Promise<void> {
266  const home = await $.env.get('HOME')
267  if (!home) return
268  const base = `${home}/.claude/indicadores`
269  const raiz = await raizAtual($)
270  const lidos: Indicador[] = []
271  for (const pasta of [base, ...(raiz ? [`${base}/${encodeURIComponent(raiz)}`] : [])]) {
272    if (!(await $.fs.exists(pasta).catch(() => false))) continue
273    const nomes = (await $.fs.list(pasta).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.json')).map(f => f.name).sort()
274    for (const nome of nomes) {
275      try {
276        lidos.push(...validarIndicadores(JSON.parse(await $.fs.read(`${pasta}/${nome}`))))
277      } catch {
278        // Um arquivo quebrado não apaga os outros.
279      }
280    }
281  }
282  const atual = await read($, indicadores)
283  if (JSON.stringify(atual) !== JSON.stringify(lidos)) await update($, indicadores, () => lidos)
284}
285
286// O caderno: ver hooks/caderno.ts (propósito e contrato). Um arquivo por projeto, só acréscimo.
287async function arquivoCaderno($: EngineInterface): Promise<string | undefined> {
288  const home = await $.env.get('HOME').catch(() => undefined)
289  if (!home) return undefined
290  await carregarObjetivo($)
291  return `${home}/.claude/caderno/${encodeURIComponent(raizCarregada ?? '')}.jsonl`
292}
293
294async function lerDoCaderno($: EngineInterface): Promise<void> {
295  const arquivo = await arquivoCaderno($)
296  const lidas = arquivo && (await $.fs.exists(arquivo)) ? lerCaderno(await $.fs.read(arquivo)) : []
297  if (JSON.stringify(await read($, caderno)) !== JSON.stringify(lidas)) await update($, caderno, () => lidas)
298}
299
300/** Acrescenta uma linha ao fim do arquivo (O_APPEND do shell: duas sessões escrevendo não se apagam). */
301async function acrescentar($: EngineInterface, texto: string): Promise<boolean> {
302  const arquivo = await arquivoCaderno($)
303  if (!arquivo) return false
304  const ok = await acrescentarEm($, arquivo, texto)
305  await lerDoCaderno($).catch(() => undefined)
306  return ok
307}
308
309async function acrescentarEm($: EngineInterface, arquivo: string, texto: string): Promise<boolean> {
310  const r = await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', '_', arquivo], { stdin: texto }).catch(() => null)
311  return r?.exitCode === 0
312}
313
314// O roadmap: ver hooks/roadmap.ts (propósito e contrato). Um arquivo por projeto, só acréscimo, como o caderno.
315async function arquivoRoadmap($: EngineInterface): Promise<string | undefined> {
316  const home = await $.env.get('HOME').catch(() => undefined)
317  if (!home) return undefined
318  await carregarObjetivo($)
319  return `${home}/.claude/roadmap/${encodeURIComponent(raizCarregada ?? '')}.jsonl`
320}
321
322async function lerDoRoadmap($: EngineInterface): Promise<ItemRoadmap[]> {
323  const arquivo = await arquivoRoadmap($)
324  const lidos = arquivo && (await $.fs.exists(arquivo).catch(() => false)) ? lerRoadmap(await $.fs.read(arquivo).catch(() => '')) : []
325  if (JSON.stringify(await read($, roadmap)) !== JSON.stringify(lidos)) await update($, roadmap, () => lidos)
326  return lidos
327}
328
329const instante = async ($: EngineInterface) => new Date(await $.clock.now().catch(() => Date.now())).toISOString()
330const sufixo = () => Math.random().toString(36).slice(2, 6)
331
332/** Grava os eventos de uma vez (um cat >> só) e relê. */
333async function gravarRoadmap($: EngineInterface, eventos: (EventoRoadmap | null)[]): Promise<boolean> {
334  const validos = eventos.filter((x): x is EventoRoadmap => !!x)
335  if (!validos.length) return true
336  const arquivo = await arquivoRoadmap($)
337  const ok = !!arquivo && (await acrescentarEm($, arquivo, validos.map(linhaRoadmap).join('')))
338  await lerDoRoadmap($).catch(() => undefined)
339  return ok
340}
341
342type AcaoRoadmap = {
343  acao?: string
344  itens?: { texto?: string; horizonte?: string; frente?: string }[]
345  ids?: number[]
346  pendencias?: number[]
347  horizonte?: string
348  frente?: string
349  nivel?: string
350  tamanho?: string
351  dono?: string
352  motivo?: string
353  citacao?: string
354}
355
356async function mudarRoadmap($: EngineInterface, a: AcaoRoadmap, autor: Owner): Promise<string> {
357  const itens = await lerDoRoadmap($)
358  const em = await instante($)
359  const fim = async (msg?: string) => `${msg ? `${msg}\n` : ''}${resumoRoadmap(await read($, roadmap))}`
360  if (a.acao === 'ver') return fim()
361  if (a.acao === 'adicionar') {
362    const evs = (a.itens ?? []).map(i => novoItem(i.texto ?? '', ehHorizonte(i.horizonte) ? i.horizonte : 'proximo', em, sufixo(), autor, i.frente))
363    if (!evs.some(Boolean)) return fim('Erro: adicionar precisa de itens com texto.')
364    if (!(await gravarRoadmap($, evs))) return fim('Erro: não consegui gravar no roadmap.')
365    return fim()
366  }
367  if (a.acao === 'adiar') {
368    const g = await read($, objetivo)
369    const ids = new Set(a.pendencias ?? [])
370    const saem = (g?.itens ?? []).filter(i => ids.has(i.id) && !i.feito)
371    if (!g || !ids.size) return fim('Erro: adiar precisa de pendencias (ids do objetivo ativo).')
372    const fora = [...ids].filter(id => !saem.some(i => i.id === id))
373    if (fora.length) return fim(`Erro: pendências inexistentes ou já feitas: ${fora.join(', ')}.`)
374    const h = ehHorizonte(a.horizonte) ? a.horizonte : 'agora'
375    if (!(await gravarRoadmap($, saem.map(i => novoItem(i.texto, h, em, sufixo(), autor, a.frente, `#${i.id}`))))) return fim('Erro: não consegui gravar no roadmap; o objetivo ficou como estava.')
376    await mudarObjetivo($, { acao: 'retirar', ids: saem.map(i => i.id) } as Acao, autor)
377    return fim(`${saem.map(i => `#${i.id}`).join(', ')} saíram do objetivo e foram para ${HORIZONTES.find(x => x.h === h)!.rotulo}.`)
378  }
379  const pedidos = new Set(a.ids ?? [])
380  if (!pedidos.size) return fim(`Erro: ${a.acao ?? 'a ação'} precisa de ids (números R).`)
381  const alvos = abertos(itens).filter(i => pedidos.has(i.n))
382  const fora = [...pedidos].filter(n => !alvos.some(i => i.n === n))
383  if (fora.length) return fim(`Erro: R${fora.join(', R')} não estão abertos no roadmap.`)
384  if (a.acao === 'mover') {
385    if (!ehHorizonte(a.horizonte)) return fim('Erro: mover precisa de horizonte (agora | proximo | depois).')
386    const h = a.horizonte
387    if (!(await gravarRoadmap($, alvos.map(i => ({ v: VERSAO_ROADMAP, ev: 'mover', id: i.id, em, horizonte: h }) as EventoRoadmap)))) return fim('Erro: não consegui gravar no roadmap.')
388    return fim()
389  }
390  if (a.acao === 'descartar') {
391    if (!a.motivo?.trim()) return fim('Erro: descartar precisa do motivo, em uma frase.')
392    const motivo = a.motivo.trim()
393    if (!(await gravarRoadmap($, alvos.map(i => ({ v: VERSAO_ROADMAP, ev: 'descartar', id: i.id, em, motivo }) as EventoRoadmap)))) return fim('Erro: não consegui gravar no roadmap.')
394    return fim()
395  }
396  if (a.acao === 'aprovar') {
397    // O operador aprova pelo botão; o Claude só registra a aprovação que o operador escreveu, com a frase dele.
398    const citacao = a.citacao?.trim()
399    if (autor === 'claude' && !citacao) return fim('Erro: aprovar pela ferramenta precisa da citacao: a frase do operador que aprovou, copiada. Sem ela, peça a aprovação pela AskUserQuestion.')
400    const novos = alvos.filter(i => !i.aprovado)
401    if (!novos.length) return fim('Nada a aprovar: os itens já estavam aprovados.')
402    if (!(await gravarRoadmap($, novos.map(i => ({ v: VERSAO_ROADMAP, ev: 'aprovar', id: i.id, em, por: autor, ...(citacao ? { citacao } : {}) }) as EventoRoadmap))))
403      return fim('Erro: não consegui gravar no roadmap.')
404    return fim(`${novos.map(i => `R${i.n}`).join(', ')} aprovado${novos.length > 1 ? 's' : ''}.`)
405  }
406  if (a.acao === 'promover') {
407    // Proposta do Claude não vira pendência sem o operador (R18); o operador promovendo aprova.
408    const semAprovacao = autor === 'claude' ? alvos.filter(i => !i.aprovado) : []
409    if (semAprovacao.length)
410      return fim(
411        `Erro: R${semAprovacao.map(i => i.n).join(', R')} ${semAprovacao.length > 1 ? 'são propostas' : 'é proposta'} sua: só vira pendência com a aprovação do operador (botão Aprovar ou Promover no painel, ou a frase dele em acao "aprovar"). Pergunte pela AskUserQuestion.`,
412      )
413    await carregarObjetivo($)
414    await doArquivoAgora($).catch(() => undefined)
415    const g = await read($, objetivo)
416    if (!g) return fim('Erro: promover precisa de um objetivo ativo; defina-o antes com a ferramenta objetivo.')
417    const base = g.itens.reduce((m, i) => Math.max(m, i.id), 0)
418    const nivel = (['bloqueante', 'requisito', 'melhoria'] as const).includes(a.nivel as Nivel) ? (a.nivel as Nivel) : 'requisito'
419    const tamanho = (['P', 'M', 'G'] as const).includes(a.tamanho as Size) ? (a.tamanho as Size) : 'M'
420    const dono: Owner = a.dono === 'operador' ? 'operador' : 'claude'
421    const r = await mudarObjetivo($, { acao: 'adicionar', itens: alvos.map(i => ({ texto: `${i.frente ? `${i.frente}: ` : ''}${i.texto}`, dono, tamanho, nivel })) }, autor)
422    if (r.erro) return fim(`Erro ao promover: ${r.texto}`)
423    await gravarRoadmap($, alvos.map((i, k) => ({ v: VERSAO_ROADMAP, ev: 'promover', id: i.id, em, pendencia: base + k + 1, por: autor }) as EventoRoadmap))
424    return fim(`${alvos.map((i, k) => `R${i.n} → #${base + k + 1}`).join(', ')} no objetivo "${g.titulo}".`)
425  }
426  return fim(`Erro: acao é uma de ver, adicionar, mover, promover, adiar, descartar, aprovar.`)
427}
428
429async function escreverNota($: EngineInterface, texto: string): Promise<void> {
430  const em = new Date(await $.clock.now().catch(() => Date.now())).toISOString()
431  const g = await read($, objetivo)
432  const prox = g ? proxima(g) : undefined
433  const n = novaNota(texto, await read($, tipoNota), em, Math.random().toString(36).slice(2, 6), prox ? `#${prox.id}` : undefined)
434  if (!n) return
435  if (await acrescentar($, linhaCaderno(n))) {
436    await update($, envios, k => k + 1)
437    await update($, rascunho, () => '')
438  }
439}
440
441async function triar($: EngineInterface, a: { nota?: string; decisao?: string; motivo?: string; destino?: string; contraponto?: string }): Promise<string> {
442  await lerDoCaderno($)
443  const notas = await read($, caderno)
444  const n = notas.find(x => x.id === a.nota)
445  if (!n) return `Erro: nota ${a.nota ?? '(sem id)'} não está no caderno. Abertas: ${semResposta(notas).map(x => x.id).join(', ') || 'nenhuma'}.`
446  if (!DECISOES.includes(a.decisao as Decisao)) return `Erro: decisao é uma de ${DECISOES.join(', ')}.`
447  if (!a.motivo?.trim()) return 'Erro: diga o motivo em uma frase; ele aparece para o operador.'
448  const em = new Date(await $.clock.now().catch(() => Date.now())).toISOString()
449  if (!(await acrescentar($, linhaCaderno(triagem(n.id, a.decisao as Decisao, a.motivo, em, a.destino, a.contraponto))))) return 'Erro: não consegui gravar no caderno.'
450  const resta = semResposta(await read($, caderno)).length
451  // Aceita (ou mudou o plano) com destino roadmap: a nota vira item de verdade, e não só uma palavra na triagem.
452  const h = a.decisao === 'aceita' || a.decisao === 'muda-plano' ? destinoRoadmap(a.destino) : null
453  let noRoadmap = ''
454  if (h && !(await read($, roadmap)).some(i => i.de === n.id)) {
455    const antes = (await lerDoRoadmap($)).length
456    const ok = await gravarRoadmap($, [novoItem(n.texto, h, em, sufixo(), 'operador', undefined, n.id)])
457    noRoadmap = ok ? ` Entrou no roadmap como R${antes + 1} (${HORIZONTES.find(x => x.h === h)!.rotulo}).` : ' Não consegui gravar no roadmap.'
458  }
459  return `Triagem gravada: ${n.id} ${a.decisao}${a.destino ? ` → ${a.destino}` : ''}.${noRoadmap} ${resta ? `Ainda sem resposta: ${resta}.` : 'Nenhuma nota sem resposta.'}`
460}
461
462/**
463 * As notas sem resposta que o Claude ainda não recebeu nesta sessão viram texto e passam a contar como entregues; as
464 * já entregues que seguem sem resposta há INTERVALO_LEMBRETE passos voltam inteiras, como lembrete.
465 */
466async function notasParaEntregar($: EngineInterface): Promise<string> {
467  await lerDoCaderno($).catch(() => undefined)
468  const ja = new Set(await read($, entregues))
469  const notas = await read($, caderno)
470  const novas = semResposta(notas).filter(n => !ja.has(n.id))
471  const passo = await read($, passos)
472  const lembrar = paraLembrar(notas, await read($, ultimaEntrega), passo)
473  const marcar = [...novas, ...lembrar].map(n => n.id)
474  if (!marcar.length) return ''
475  if (novas.length) await update($, entregues, l => [...l, ...novas.map(n => n.id)])
476  await update($, ultimaEntrega, u => ({ ...u, ...Object.fromEntries(marcar.map(id => [id, passo])) }))
477  const agoraMs = await $.clock.now().catch(() => Date.now())
478  return [novas.length ? entrega(novas, ja.size === 0, agoraMs) : '', lembrete(lembrar, agoraMs)].filter(Boolean).join(' ')
479}
480
481// O desenho dos gauges depende do terminal, que não muda durante a sessão: lido uma vez.
482let estiloLido: EstiloGauge | undefined
483async function estiloGauge($: EngineInterface): Promise<EstiloGauge> {
484  if (estiloLido) return estiloLido
485  // Nomes literais: o motor lista as variáveis que um mod lê.
486  const forcado = (await $.env.get('CONTEXTO_BAR_GAUGE').catch(() => undefined)) ?? undefined
487  const kitty = (await $.env.get('KITTY_WINDOW_ID').catch(() => undefined)) ?? undefined
488  const term = (await $.env.get('TERM').catch(() => undefined)) ?? undefined
489  const termProgram = (await $.env.get('TERM_PROGRAM').catch(() => undefined)) ?? undefined
490  estiloLido = estiloDoTerminal({ forcado, kitty, term, termProgram })
491  return estiloLido
492}
493
494// Quem executa: o modelo e o effort de cada requisição (turn.step), a conversa principal e cada subagente
495// (agent.spawn dá o id, o tipo e a descrição; o fim do turno do subagente o tira).
496async function registrarPasso($: EngineInterface, e: { model: string; effort?: string | number; agentId?: string }): Promise<void> {
497  const effort = e.effort === undefined ? undefined : String(e.effort)
498  await update($, executores, ex => {
499    if (!e.agentId) return { ...ex, principal: { modelo: e.model, ...(effort ? { effort } : {}) } }
500    return { ...ex, subagentes: ex.subagentes.map(s => (s.id === e.agentId ? { ...s, modelo: e.model, ...(effort ? { effort } : {}) } : s)) }
501  })
502}
503
504/** Estado do agente para o cartão Agora; nunca derruba o evento observado. */
505async function marcar($: EngineInterface, a: Omit<Atividade, 'desde'>): Promise<void> {
506  const desde = await $.clock.now().catch(() => Date.now())
507  await update($, atividade, cur => (cur && cur.estado === a.estado && cur.ferramenta === a.ferramenta ? cur : { ...a, desde })).catch(() => undefined)
508}
509
510// Ferramentas em paralelo viram uma leitura em andamento e, no máximo, mais uma logo depois.
511let reading: Promise<void> | null = null
512let again = false
513async function refreshSoon($: EngineInterface, hookTexts: Set<string>): Promise<void> {
514  if (reading) {
515    again = true
516    return reading
517  }
518  reading = (async () => {
519    do {
520      again = false
521      await refresh($, hookTexts)
522    } while (again)
523  })().finally(() => {
524    reading = null
525  })
526  return reading
527}
528
529// O objetivo vive no $.state da sessão e é gravado num arquivo por pasta de projeto: fechar a sessão ou
530// reiniciar a máquina não o perde, e cada projeto retoma o seu. Não no $.store: ele é um arquivo só para
531// todos os projetos, que cada sessão guarda inteiro na memória e regrava inteiro, e duas sessões abertas
532// apagavam o objetivo uma da outra. A chave do store fica para migrar o que foi gravado lá.
533export const chaveObjetivo = (raiz: string) => `objetivo:${raiz}`
534export const arquivoObjetivo = (home: string, raiz: string) => `${home}/.claude/objetivos/${encodeURIComponent(raiz)}.json`
535// Pasta cujo objetivo está no $.state agora (some num reload do módulo; aí recarrega do store).
536let raizCarregada: string | undefined
537// Objetivo (pasta, título e conclusão) cuja conclusão já foi ao modelo: a linha completa sai uma vez por objetivo.
538let conclusaoEnviada: string | undefined
539
540async function raizAtual($: EngineInterface): Promise<string> {
541  try {
542    return await $.session.root()
543  } catch {
544    try {
545      return await $.session.cwd()
546    } catch {
547      return ''
548    }
549  }
550}
551
552// Gravar é o melhor esforço: sem HOME nem disco, o objetivo vai ao store; sem store, fica só na sessão.
553async function caminhoDoObjetivo($: EngineInterface, raiz: string): Promise<string | undefined> {
554  const home = await $.env.get('HOME').catch(() => undefined)
555  return home ? arquivoObjetivo(home, raiz) : undefined
556}
557
558async function lerDoStore($: EngineInterface, raiz: string): Promise<Goal | null> {
559  try {
560    return ((await $.store.get(chaveObjetivo(raiz))) ?? null) as Goal | null
561  } catch {
562    return null
563  }
564}
565
566async function lerSalvo($: EngineInterface, raiz: string): Promise<Goal | null> {
567  const arquivo = await caminhoDoObjetivo($, raiz)
568  if (!arquivo) return lerDoStore($, raiz)
569  try {
570    if (await $.fs.exists(arquivo)) {
571      // O arquivo existe: vale ele, mesmo dizendo "sem objetivo" (encerrado), e o store não é mais lido.
572      const salvo = JSON.parse(await $.fs.read(arquivo)) as { raiz?: string; objetivo?: Goal | null }
573      return salvo.raiz === raiz ? (salvo.objetivo ?? null) : null
574    }
575  } catch {
576    return null
577  }
578  // Ainda sem arquivo: o que estava no store passa para ele e sai do store.
579  const antigo = await lerDoStore($, raiz)
580  if (antigo && (await escreverArquivo($, arquivo, raiz, antigo))) await $.store.delete(chaveObjetivo(raiz)).catch(() => undefined)
581  return antigo
582}
583
584async function escreverArquivo($: EngineInterface, arquivo: string, raiz: string, g: Goal | null): Promise<boolean> {
585  try {
586    const texto = `${JSON.stringify({ raiz, objetivo: g }, null, 2)}\n`
587    await $.fs.write(arquivo, texto)
588    textoVisto = texto
589    return true
590  } catch {
591    return false
592  }
593}
594
595// Cache de prompt: o transcript dá a semente uma vez por sessão (ou ao carregar o mod numa sessão em curso);
596// depois cada turno da conversa principal soma o seu uso, sem reler o arquivo. Um transcript acima do limite de
597// leitura (4 MiB) não semeia: a conta vale desde o carregamento e o painel diz que é parcial.
598const LIMITE_LEITURA = 4 * 1024 * 1024
599async function semearCache($: EngineInterface): Promise<void> {
600  if (await read($, cache)) return
601  let semente = { acc: CACHE_VAZIO, parcial: false }
602  try {
603    const home = await $.env.get('HOME')
604    const id = await $.session.id()
605    if (home && id) {
606      const pastas = [...new Set([await $.session.cwd().catch(() => ''), await raizAtual($)])].filter(Boolean)
607      const arquivo = (await Promise.all(pastas.map(async d => ((await $.fs.exists(caminhoTranscript(home, d, id))) ? caminhoTranscript(home, d, id) : '')))).find(Boolean)
608      if (arquivo) {
609        const st = await $.fs.stat(arquivo)
610        semente = st.size > LIMITE_LEITURA ? { acc: CACHE_VAZIO, parcial: true } : { acc: doTranscript(await $.fs.read(arquivo)), parcial: false }
611      }
612    }
613  } catch {
614    semente = { acc: CACHE_VAZIO, parcial: true }
615  }
616  await update($, cache, c => c ?? semente)
617}
618
619// Segundo plano: só observa e nunca trava. Um erro aqui fica aqui, sem chegar à chamada observada.
620const agora = ($: EngineInterface) => $.clock.now().catch(() => Date.now())
621async function mudarSegundoPlano($: EngineInterface, fn: (sp: SegundoPlano) => SegundoPlano): Promise<void> {
622  await update($, segundoPlano, sp => fn(sp ?? SP_VAZIO)).catch(() => undefined)
623}
624
625/** Relê a hora da última escrita de cada saída em curso (um stat por tarefa, sem ler o arquivo). */
626async function olharSaidas($: EngineInterface): Promise<void> {
627  const sp = await read($, segundoPlano)
628  if (!sp?.tarefas.some(t => t.saida)) return
629  const vistos = new Map<string, number>()
630  for (const t of sp.tarefas) {
631    if (!t.saida) continue
632    const st = await $.fs.stat(t.saida).catch(() => undefined)
633    if (st) vistos.set(t.id, st.mtimeMs)
634  }
635  await mudarSegundoPlano($, cur => ({ ...cur, tarefas: cur.tarefas.map(t => (vistos.has(t.id) ? { ...t, mtime: vistos.get(t.id) } : t)) }))
636}
637
638/** O que a chamada da conversa principal diz do segundo plano: tarefa nova, volta agendada, tarefa parada. */
639async function observarChamada($: EngineInterface, e: { tool: string; [k: string]: unknown }, result: { result?: unknown; text?: string }): Promise<void> {
640  const quando = await agora($)
641  const r = (result.result ?? {}) as { backgroundTaskId?: string; backgroundEndsWithFinalResponse?: true }
642  if (e.tool === 'Bash' && r.backgroundTaskId && !r.backgroundEndsWithFinalResponse) {
643    const descricao = String(e.description || e.command || r.backgroundTaskId).trim()
644    await mudarSegundoPlano($, sp => registrarTarefa(sp, { id: r.backgroundTaskId!, descricao, saida: caminhoSaida(result.text), inicio: quando }))
645  } else if (FERRAMENTAS_DE_VOLTA.includes(e.tool)) {
646    await mudarSegundoPlano($, sp => ({ ...sp, voltaEm: quando }))
647  } else if (e.tool === 'TaskStop' && (e.task_id ?? e.shell_id) != null) {
648    // O id em qualquer forma: um TaskStop legítimo nunca é ignorado pelo tipo (sobrevivente #13 da 0.10.0).
649    await mudarSegundoPlano($, sp => concluirTarefa(sp, String(e.task_id ?? e.shell_id)))
650  }
651}
652
653/** O aviso do segundo plano para a linha do próximo pedido; vazio sem alerta. */
654async function avisoSegundoPlano($: EngineInterface): Promise<string> {
655  await olharSaidas($).catch(() => undefined)
656  const sp = await read($, segundoPlano)
657  return sp ? avisoAoModelo(alertas(sp, await agora($))) : ''
658}
659
660// Painel automático: com objetivo ativo, cada pedido abre (ou mantém) o painel. Liga por padrão.
661const CHAVE_AUTO = 'painel-auto'
662async function painelAuto($: EngineInterface): Promise<boolean> {
663  const espelho = await read($, autoEspelho).catch(() => null)
664  if (espelho !== null) return espelho
665  try {
666    return (await $.store.get(CHAVE_AUTO)) !== false
667  } catch {
668    return true
669  }
670}
671
672async function mudarAuto($: EngineInterface, ligado: boolean): Promise<void> {
673  await $.store.set(CHAVE_AUTO, ligado).catch(() => undefined)
674  await update($, autoEspelho, () => ligado)
675}
676
677async function abrirPainel($: EngineInterface) {
678  // Aberto do zero, a superfície mostra a PRIMEIRA aba aberta; reabrir uma aba que já está aberta só a retitula e não
679  // a traz para a frente (print da sessão real, 16/10 02:25: na ordem antiga, Caderno primeiro, o Caderno ficou na
680  // frente). A Home abre primeiro: é a mostrada, e a barra fica Home, Caderno, Prova, Agentes.
681  const home = await $.ui.open({ id: PANE, title: ABAS[0].titulo, closeOnEscape: true, columns: 48 })
682  for (const a of ABAS.slice(1)) await $.ui.open({ id: a.id, title: a.titulo, closeOnEscape: true, columns: 48 }).catch(() => undefined)
683  return home
684}
685
686/** Fecha as abas que vão junto com a que o operador fechou. */
687export async function fecharAsOutras($: Pick<EngineInterface, 'ui'>, e: { id: string; origin: { kind: string } }): Promise<void> {
688  // A origem é um objeto ({ kind }), não o texto: comparar o objeto com 'person' nunca batia (visto na sessão real, 16/10).
689  for (const id of fecharJunto(e.id, e.origin?.kind)) await $.ui.close({ id }).catch(() => undefined)
690}
691
692/** O operador fechou uma aba (Esc, a marca): as outras fecham junto, como o painel de uma aba só fechava. */
693export function fecharJunto(id: string, origem: string | undefined): string[] {
694  if (origem !== 'person' || !ABAS.some(a => a.id === id)) return []
695  return ABAS.map(a => a.id).filter(x => x !== id)
696}
697
698/** Traz para a sessão o objetivo gravado da pasta atual, se a sessão ainda não o tem. */
699async function carregarObjetivo($: EngineInterface): Promise<void> {
700  const raiz = await raizAtual($)
701  if (raiz === raizCarregada) return
702  const primeiraCarga = raizCarregada === undefined
703  raizCarregada = raiz
704  const r = aoCarregar(primeiraCarga, await lerSalvo($, raiz), await read($, objetivo), usado(await read($, snapshot)), await read($, turno))
705  if (r.gravar) await gravarObjetivo($, () => r.goal)
706  else await update($, objetivo, () => r.goal)
707}
708
709// Duas sessões na mesma pasta dividem o arquivo do objetivo. O arquivo é a fonte da verdade: antes de mudar,
710// a sessão relê o que outra gravou e aplica a mudança por cima; depois de cada ferramenta e a cada pedido, relê
711// para mostrar o que mudou lá. Sem isso, cada sessão regravava a sua cópia inteira e apagava a outra.
712let textoVisto: string | undefined
713async function doArquivoAgora($: EngineInterface): Promise<void> {
714  const raiz = raizCarregada
715  if (raiz === undefined) return
716  const arquivo = await caminhoDoObjetivo($, raiz)
717  if (!arquivo || !(await $.fs.exists(arquivo).catch(() => false))) return
718  const texto = await $.fs.read(arquivo).catch(() => undefined)
719  if (texto === undefined || texto === textoVisto) return
720  try {
721    const salvo = JSON.parse(texto) as { raiz?: string; objetivo?: Goal | null }
722    if (salvo.raiz !== raiz) return
723    textoVisto = texto
724    await update($, objetivo, () => salvo.objetivo ?? null)
725  } catch {
726    // Arquivo pela metade (outra sessão gravando): fica a cópia da sessão até a próxima leitura.
727  }
728}
729
730/** Muda o objetivo da sessão e grava o resultado na chave da pasta. */
731async function gravarObjetivo($: EngineInterface, fn: (g: Goal | null) => Goal | null): Promise<void> {
732  await carregarObjetivo($)
733  await doArquivoAgora($).catch(() => undefined)
734  await update($, objetivo, fn)
735  const g = await read($, objetivo)
736  const raiz = raizCarregada ?? ''
737  const arquivo = await caminhoDoObjetivo($, raiz)
738  if (arquivo && (await escreverArquivo($, arquivo, raiz, g))) return
739  const chave = chaveObjetivo(raiz)
740  try {
741    if (g) await $.store.set(chave, g)
742    else await $.store.delete(chave)
743  } catch {
744    // Sem store: fica só na sessão.
745  }
746}
747
748/** Aplica uma ação ao objetivo e devolve o resumo (ou o erro). */
749async function mudarObjetivo($: EngineInterface, a: Acao, origem: 'claude' | 'operador'): Promise<{ texto: string; erro: boolean }> {
750  await carregarObjetivo($)
751  await doArquivoAgora($).catch(() => undefined)
752  const snap = await read($, snapshot)
753  const atual = await read($, objetivo)
754  const n = await read($, turno)
755  const r = aplicar(atual, a, origem, usado(snap), n)
756  if (r.erro) return { texto: `Erro: ${r.erro}\n${resumo(atual, snap)}`, erro: true }
757  // Encerrar não perde o plano: as pendências abertas voltam ao roadmap, em agora, antes do objetivo sair.
758  const abertas = a.acao === 'encerrar' ? (atual?.itens ?? []).filter(i => !i.feito) : []
759  let devolvidas = ''
760  if (abertas.length) {
761    await lerDoRoadmap($).catch(() => [])
762    const em = await instante($)
763    const ok = await gravarRoadmap($, abertas.map(i => novoItem(i.texto, 'agora', em, sufixo(), origem, undefined, `#${i.id}`)))
764    if (!ok) return { texto: `Erro: não consegui devolver as ${abertas.length} pendências abertas ao roadmap; o objetivo segue ativo.\n${resumo(atual, snap)}`, erro: true }
765    devolvidas = `\n${abertas.length === 1 ? 'A pendência aberta voltou' : `As ${abertas.length} pendências abertas voltaram`} ao roadmap, em agora.`
766  }
767  await gravarObjetivo($, () => r.goal)
768  if (devolvidas) return { texto: resumo(r.goal, snap) + devolvidas, erro: false }
769  const checagem = r.goal && (a.acao === 'definir' || a.acao === 'adicionar') ? `\n${alinhamento(r.goal)}` : ''
770  return { texto: resumo(r.goal, snap) + checagem, erro: false }
771}
772
773/** Texto de aviso de janela, quando a projeção piorou desde o último aviso. */
774async function checarAviso($: EngineInterface): Promise<string | undefined> {
775  const g = await read($, objetivo)
776  if (!g) return undefined
777  const { nivel, texto } = avisoJanela(g, await read($, snapshot))
778  if (nivel !== g.aviso) await gravarObjetivo($, cur => (cur ? { ...cur, aviso: nivel } : cur))
779  return texto
780}
781
782export const register: Register = on => {
783  // Texto injetado por hooks na janela atual, sem repetir o mesmo bloco.
784  const hookTexts = new Set<string>()
785
786  on('session.start', async ($, e, next) => {
787    await $.tool.register(TOOL_SPEC)
788    await $.tool.register(ROADMAP_SPEC)
789    await $.command.register({
790      name: 'objetivo',
791      description: 'Objetivo em curso: sem argumento abre o painel; <texto> [:: passo que conclui] define; feito <ids>; bloqueante|requisito|melhoria <ids>; meu <texto>; nota [ideia|agora|depois|referencia] [#id] <texto>; auto on|off; encerrar',
792      argumentHint: '[texto :: conclui | feito 1 2 | bloqueante 3 | meu texto | encerrar]',
793    })
794    const result = await next(e)
795    await refresh($, hookTexts)
796    await carregarObjetivo($)
797    await lerDoRoadmap($).catch(() => undefined)
798    await semearCache($)
799    // O que muda com esta sessão parada ou presa numa ferramenta: a rodada do prova-de-teste e, dentro do herdr, os
800    // vizinhos. Relidos a cada 2 s, o painel e a faixa acompanham sem esperar um turno.
801    if (!e.agentId) {
802      const noHerdr = !!(await $.env.get('HERDR_PANE_ID').catch(() => undefined))
803      timerLeitura?.cancel()
804      timerLeitura = $.clock.every(INTERVALO_LEITURA_MS, () => {
805        void lerAoVivo($).catch(() => undefined)
806        if (noHerdr) void lerVizinhos($).catch(() => undefined)
807      })
808    }
809    return result
810  })
811
812  // A ferramenta do objetivo: o modelo define e atualiza; a resposta é o estado com a projeção.
813  on('tool.call', { tool: TOOL as never }, async ($, e) => {
814    if ((e as { acao?: string }).acao === 'triar') {
815      const r = await triar($, e as never)
816      return { result: r } as never
817    }
818    const r = await mudarObjetivo($, e as unknown as Acao, 'claude')
819    return { result: r.texto } as never
820  })
821
822  on('tool.call', { tool: ROADMAP_TOOL as never }, async ($, e) => {
823    return { result: await mudarRoadmap($, e as unknown as AcaoRoadmap, 'claude') } as never
824  })
825
826  // Cada prompt leva ao modelo uma linha com o objetivo e as pendências (o operador não a vê).
827  on('prompt.submit', async ($, e, next) => {
828    if (e.command) return next(e)
829    await marcar($, { estado: 'trabalhando' })
830    // A notificação de fim de uma tarefa limpa o alerta dela.
831    const nota = e.origin?.kind === 'task-notification' || e.text.includes('<task-notification>') ? notificacao(e.text) : null
832    if (nota) await mudarSegundoPlano($, sp => concluirTarefa(sp, nota.id))
833    // Um /cd muda a pasta do projeto: a linha passa a ser a do objetivo dela.
834    await carregarObjetivo($)
835    await doArquivoAgora($).catch(() => undefined)
836    const atual = await read($, objetivo)
837    // Pedido do operador: ver o objetivo desde o início da atividade, sem esperar o fim do ciclo.
838    if (atual && (await painelAuto($))) await abrirPainel($).catch(() => undefined)
839    // A conclusão vai uma vez por objetivo (recém-definido ou início de sessão); depois, só a linha curta.
840    const chave = atual ? `${raizCarregada}\n${atual.titulo}\n${atual.conclusao}` : undefined
841    const completa = chave !== undefined && chave !== conclusaoEnviada
842    if (chave !== undefined) conclusaoEnviada = chave
843    const doPlano = lembreteRoadmap(await lerDoRoadmap($).catch(() => []), !atual || !proxima(atual))
844    const linhas = [linhaContexto(atual, await read($, snapshot), TOOL, completa) + doPlano + (await avisoSegundoPlano($).catch(() => '')) + pendenciaDoCaderno(await read($, caderno))]
845    const doCaderno = await notasParaEntregar($).catch(() => '')
846    if (doCaderno) linhas.push(doCaderno)
847    const aviso = await checarAviso($)
848    if (aviso) linhas.push(aviso)
849    return next({ ...e, context: [...(e.context ?? []), ...linhas] })
850  })
851
852  on('command.run', { command: 'objetivo' }, async ($, e) => {
853    // /objetivo nota [ideia|agora|depois|referencia] [#id] texto: o caderno por comando (superfícies sem campo, atalho).
854    const mNota = /^nota\s+([\s\S]+)$/i.exec(e.args.trim())
855    if (mNota) {
856      const [primeira, ...resto] = mNota[1]!.trim().split(/\s+/)
857      const t = TIPOS.find(x => x.tipo === primeira || x.rotulo === primeira)
858      if (t) await update($, tipoNota, () => t.tipo)
859      const antes = (await read($, caderno)).length
860      await escreverNota($, t ? resto.join(' ') : mNota[1]!)
861      const gravou = (await read($, caderno)).length > antes
862      return { text: gravou ? 'Nota no caderno: o Claude recebe junto do resultado da próxima ferramenta e responde a ela.' : 'Nota vazia ou caderno indisponível: nada gravado.' }
863    }
864    const c = comando(e.args)
865    if (c === 'auto-on' || c === 'auto-off') {
866      await mudarAuto($, c === 'auto-on')
867      return {
868        text:
869          c === 'auto-on'
870            ? 'Painel do objetivo: abre sozinho a cada pedido enquanto houver objetivo ativo.'
871            : 'Painel do objetivo: não abre mais sozinho; use /objetivo para abrir. /objetivo auto on volta.',
872      }
873    }
874    if (c === 'painel') {
875      // A Home que falha ao abrir não derruba o comando: o motor trocaria o erro por "no implementation".
876      const r = await abrirPainel($).catch(() => ({ isPlaced: false }))
877      return { text: r.isPlaced ? 'Painel do objetivo aberto (Esc fecha).' : resumo(await read($, objetivo), await read($, snapshot)) }
878    }
879    const r = await mudarObjetivo($, c, 'operador')
880    return { text: r.texto }
881  })
882
883  on('prompt.attachment', async ($, e, next) => {
884    if (e.origin.kind === 'hook' && !e.agentId) hookTexts.add(e.text)
885    return next(e)
886  })
887
888  // Evento de stream: o hook é um gerador; observa o modelo e o effort e repassa a resposta inteira.
889  on('turn.step', async function* ($, e, next) {
890    await registrarPasso($, e).catch(() => undefined)
891    return yield* next(e)
892  })
893
894  on('agent.spawn', async ($, e, next) => {
895    const r = await next(e)
896    if (r?.agentId) {
897      const desde = await $.clock.now().catch(() => Date.now())
898      const sub = { id: r.agentId, tipo: e.subagentType, descricao: e.description, modelo: r.model, desde }
899      await update($, executores, ex => ({ ...ex, subagentes: [...ex.subagentes.filter(s => s.id !== sub.id), sub] })).catch(() => undefined)
900    }
901    return r
902  })
903
904  on('turn.complete', async ($, e, next) => {
905    const result = await next(e)
906    if (e.agentId) await update($, executores, ex => ({ ...ex, subagentes: ex.subagentes.filter(s => s.id !== e.agentId) })).catch(() => undefined)
907    await update($, turno, n => n + 1)
908    if (!e.agentId) await marcar($, { estado: 'concluido' })
909    if (!e.agentId) {
910      // Sem semente ainda, o transcript já traz este turno: semeia em vez de somar, para não contar duas vezes.
911      const uso = result.usage ?? e.usage
912      if (!(await read($, cache))) await semearCache($)
913      else if (uso) await update($, cache, c => (c ? { ...c, acc: somar(c.acc, uso) } : c))
914    }
915    if (!e.agentId) {
916      const quando = await agora($)
917      await mudarSegundoPlano($, sp => fimDeTurno(sp, quando))
918    }
919    await refresh($, hookTexts)
920    return result
921  })
922
923  // O Stop clássico diz o que ainda roda e se há crons para acordar a sessão: a fonte certa no fim do turno.
924  on('classic.Stop', async ($, e, next) => {
925    const result = await next(e)
926    if (!e.agent_id) {
927      const quando = await agora($)
928      await mudarSegundoPlano($, sp => fimDeTurno(sp, quando, e.background_tasks, e.session_crons?.length))
929      // Resposta que acaba pedindo licença para o que já foi aprovado: o turno continua, com o motivo. Uma vez só
930      // (stop_hook_active): se o modelo parar de novo, para.
931      if (!e.stop_hook_active && !result.block) {
932        const motivo = await (async () => bloqueioDeLicenca(e.last_assistant_message ?? '', await read($, objetivo), await lerDoRoadmap($).catch(() => [])))().catch(() => undefined)
933        if (motivo) return { ...result, block: motivo }
934      }
935    }
936    return result
937  })
938
939  // Quase tempo real: relê depois de cada ferramenta da conversa principal (estimativa local, sem custo).
940  on('tool.call', async ($, e, next) => {
941    if (!e.agentId) await marcar($, { estado: 'trabalhando', ferramenta: e.tool as string })
942    if (!e.agentId) await update($, passos, n => n + 1).catch(() => undefined)
943    const result = await next(e)
944    if (!e.agentId) await marcar($, { estado: 'trabalhando' })
945    if (e.agentId || (e.tool as string) === TOOL || (e.tool as string) === ROADMAP_TOOL) return result
946    await refreshSoon($, hookTexts)
947    await doArquivoAgora($).catch(() => undefined)
948    await lerDoRoadmap($).catch(() => undefined)
949    if (result.deny !== undefined || result.isError) return result
950    await observarChamada($, e as never, result).catch(() => undefined)
951    // No meio do turno: se a projeção piorou, o aviso chega junto do resultado da ferramenta.
952    const notas = [await checarAviso($), await notasParaEntregar($).catch(() => '')]
953    // Publicar com a verificação aberta: a nota vem com o resultado (o mod não tem como falar antes da chamada).
954    if ((e.tool as string) === 'Bash') notas.push(notaPublicacao(await read($, objetivo), String((e as { command?: unknown }).command ?? '')))
955    const ctx = notas.filter((n): n is string => !!n)
956    return ctx.length ? { ...result, context: [...(result.context ?? []), ...ctx] } : result
957  })
958
959  // A compactação descarta o texto que os hooks tinham injetado; a estimativa de Hooks recomeça do zero.
960  on('session.compact', async ($, e, next) => {
961    const result = await next(e)
962    if (!e.agentId && e.trigger !== 'precompute' && !result.skip) {
963      hookTexts.clear()
964      await refresh($, hookTexts)
965      const uso = usado(await read($, snapshot))
966      await gravarObjetivo($, g => aposCompactar(g, uso))
967    }
968    return result
969  })
970
971  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
972    const snap = await read($, snapshot)
973    if (e.props.hasSurvey || snap === null) return next(e)
974    const goal = await read($, objetivo)
975    const agora = await read($, turno)
976    const pc = taxa((await read($, cache))?.acc ?? null)
977    const plano = goal && proxima(goal) ? [] : ((await read($, roadmap)) ?? [])
978    const vizinhosNaFaixa = faixaVizinhos(await read($, vizinhos))
979
980    const { Box, Text } = $.ui.resolve(e)
981    const suffix = ` ${snap.percent}%`
982    // O acerto do cache vai no fim da linha da barra só se couber sem quebrar; o detalhe fica no painel.
983    const cabeCache = pc !== null && e.props.bodyColumns - suffix.length - ` · cache ${pc}%`.length - 2 >= 10
984    const extra = cabeCache ? ` · cache ${pc}%` : ''
985    const width = Math.max(10, e.props.bodyColumns - suffix.length - extra.length - 2)
986    const cells = cellsFor(snap, width)
987    const pctColor = snap.percent >= 80 ? 'error' : snap.percent >= 60 ? 'warning' : 'text'
988
989    return (
990      <Box flexDirection="column">
991        <Text>
992          [
993          {GROUPS.map((g, i) =>
994            (cells[i] ?? 0) > 0 ? (
995              <Text key={g.key} color={g.color}>
996                {g.char.repeat(cells[i] ?? 0)}
997              </Text>
998            ) : null,
999          )}
1000          ]
1001          <Text color={pctColor} weight="bold">
1002            {suffix}
1003          </Text>
1004          {extra ? <Text color="subtle">{extra}</Text> : null}
1005        </Text>
1006        <Text wrap="wrap">
1007          {GROUPS.map((g, i) => (
1008            <Text key={g.key} color={g.color}>
1009              {g.char === '█' ? '■' : g.char} {g.label} {fmtTokens(snap.segments[i]?.tokens ?? 0)}
1010              {'  '}
1011            </Text>
1012          ))}
1013        </Text>
1014        {goal ? <GoalBand goal={goal} snap={snap} agora={agora} Text={Text} /> : null}
1015        {!goal || !proxima(goal) ? (
1016          <Text dimColor={!plano.length} wrap="truncate">
1017            {faixaRoadmap(plano, e.props.bodyColumns, goal ? '  ↳ objetivo feito' : '◎ sem objetivo ativo')}
1018          </Text>
1019        ) : null}
1020        {vizinhosNaFaixa ? (
1021          <Text color="warning" wrap="truncate">
1022            {vizinhosNaFaixa}
1023          </Text>
1024        ) : null}
1025      </Box>
1026    )
1027  })
1028
1029  // O painel, de cima para baixo: medidores, o cartão Agora, a vez do operador com botões, a fila do Claude e as
1030  // feitas recolhidas. O texto longo de cada pendência aparece inteiro com o ponteiro em cima (hover).
1031  // Acoplado ao lado da conversa no modo tela cheia; acima do prompt fora dele.
1032  for (const a of ABAS) on('ui.render', { component: 'Pane', requestId: a.id }, ($, e) => desenharPainel($, e, a.id))
1033
1034  on('ui.close', async ($, e, next) => {
1035    const result = await next(e)
1036    await fecharAsOutras($, e)
1037    return result
1038  })
1039}
1040
1041// O painel, aba por aba. Objetivo: medidores do ciclo, o Agora, a vez do operador, a fila, o roadmap e as feitas.
1042// Caderno: a porta do operador. Prova: os indicadores e a rodada ao vivo. Agentes: quem executa, os outros
1043// workspaces e o segundo plano.
1044async function desenharPainel($: EngineInterface, e: any, aba: Aba) {
1045  {
1046    const elementos = $.ui.resolve(e)
1047    const { Box, Text, Button } = elementos
1048    // O celular não tem campo de texto: lá a nota vai pelo /objetivo nota <texto>.
1049    const Input = e.surface !== 'mobile' && 'Input' in elementos ? elementos.Input : undefined
1050    const largura = Math.max(30, (e.props as { bodyColumns?: number }).bodyColumns ?? 48)
1051    const goal = await read($, objetivo)
1052    const snap = await read($, snapshot)
1053    const sp = await read($, segundoPlano)
1054    const pc = taxa((await read($, cache))?.acc ?? null)
1055    const inds = await read($, indicadores)
1056    const ativ = await read($, atividade)
1057    const feitasAbertas = await read($, verFeitas)
1058    const auto = await painelAuto($)
1059    const p = goal ? projetar(goal, snap) : null
1060
1061    // Gauges em arco, lado a lado (braille no Kitty, Ghostty e foot; caixa nos outros): o número no meio, o rótulo
1062    // embaixo, o detalhe com o ponteiro em cima. Quebram para a linha de baixo quando o painel é estreito.
1063    const estilo = e.surface === 'terminal' ? await estiloGauge($) : 'caixa'
1064    const desenharGauge = (id: string, rotulo: string, frac: number, centro: string, cor: string, detalhe?: string) => (
1065      <Box key={`g-${id}`} flexDirection="column" width={LARGURA_GAUGE}>
1066        {gauge(frac, centro, estilo).map((linha, l) => (
1067          <Text key={`g-${id}-${l}`}>
1068            {pedacos(linha).map((pd, k) => (
1069              <Text key={`g-${id}-${l}-${k}`} color={pd.cheio === true ? cor : pd.cheio === false ? 'inactive' : l === LINHAS_GAUGE - 1 ? cor : undefined} weight={pd.cheio === null && l === LINHAS_GAUGE - 1 && pd.texto.trim() ? 'bold' : undefined}>
1070                {pd.texto}
1071              </Text>
1072            ))}
1073          </Text>
1074        ))}
1075        <Text dimColor>{centralizar(rotulo, LARGURA_GAUGE)}</Text>
1076        {detalhe ? (
1077          <Box display="none" hover={{ display: 'flex' }}>
1078            <Text dimColor wrap="wrap">
1079              {detalhe}
1080            </Text>
1081          </Box>
1082        ) : null}
1083      </Box>
1084    )
1085    const gauges = [
1086      p && p.total ? desenharGauge('pend', 'pendências', p.feitas / p.total, `${p.feitas}/${p.total}`, 'claude', `Claude ${p.abertasClaude} · você ${p.abertasOperador}`) : null,
1087      snap
1088        ? desenharGauge('jan', 'janela', snap.percent / 100, `${snap.percent}%`, snap.percent >= 80 ? 'error' : snap.percent >= 60 ? 'warning' : 'success', p ? `${ESTADO[p.estado]} · precisa ~${fmtTokens(p.necessario)} de ${fmtTokens(p.livre)}` : undefined)
1089        : null,
1090    ].filter(Boolean)
1091    // Os indicadores de outros mods (o EXP-001 do prova-de-teste) vão para a aba Prova.
1092    const medidoresProva = inds.length ? (
1093      <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
1094        {inds.map(i =>
1095          desenharGauge(`${i.fonte}-${i.id}`, i.rotulo, i.meta ? i.valor / i.meta : 0, centroIndicador(i), i.estado ? COR_INDICADOR[i.estado] : 'claude', i.detalhe),
1096        )}
1097      </Box>
1098    ) : null
1099
1100    // Uma pendência numa linha; o resto do texto aparece embaixo com o ponteiro em cima.
1101    const linhaItem = (i: GoalItem, glifo: string, cor: string | undefined, reserva: number, botao?: RenderChildren) => {
1102      const prefixo = `${glifo} #${i.id} `
1103      const { curto, resto } = encurtar(i.texto, largura - prefixo.length - reserva)
1104      return (
1105        <Box key={`i-${i.id}`} flexDirection="column">
1106          <Box flexDirection="row" justifyContent="space-between">
1107            <Text dimColor={i.feito || i.nivel === 'melhoria'}>
1108              <Text color={cor}>{glifo}</Text> #{i.id} {curto}
1109              {i.disparo ? <Text dimColor> {textoDisparo(i.disparo)}</Text> : null}
1110            </Text>
1111            {botao ?? null}
1112          </Box>
1113          {resto ? (
1114            <Box display="none" hover={{ display: 'flex' }} paddingLeft={prefixo.length}>
1115              <Text dimColor wrap="wrap">
1116                …{resto}
1117              </Text>
1118            </Box>
1119          ) : null}
1120        </Box>
1121      )
1122    }
1123
1124    const est = ativ?.estado ?? 'ocioso'
1125    const ex = await read($, executores)
1126    const quem = ex.principal ? `${nomeModelo(ex.principal.modelo)}${ex.principal.effort ? ` · effort ${ex.principal.effort}` : ''}` : ''
1127    const subs = ex.subagentes
1128    const agoraCard = (
1129      <Box flexDirection="column" marginTop={1}>
1130        <Text>
1131          <Text weight="bold">AGORA </Text>
1132          <Text color={ESTADOS[est].cor}>
1133            {ESTADOS[est].glifo} {textoAtividade(ativ)}
1134          </Text>
1135        </Text>
1136        {quem || subs.length ? (
1137          <Text key="quem" dimColor>
1138            {quem}
1139            {subs.length ? `${quem ? ' · ' : ''}${subs.length} ${subs.length === 1 ? 'subagente' : 'subagentes'} (aba Agentes)` : ''}
1140          </Text>
1141        ) : null}
1142        {goal ? (
1143          (() => {
1144            const prox = proxima(goal)
1145            if (!prox) return <Text color="success">tudo feito: encerrar ou definir o próximo objetivo</Text>
1146            const cor = prox.nivel === 'bloqueante' ? 'error' : prox.dono === 'operador' ? 'warning' : 'claude'
1147            return (
1148              <Box flexDirection="column">
1149                <Text color={cor} weight="bold" wrap="wrap">
1150                  ▣ #{prox.id} {prox.texto}
1151                  {prox.disparo ? <Text dimColor> {textoDisparo(prox.disparo)}</Text> : null}
1152                </Text>
1153                <Text dimColor>
1154                  {'  '}
1155                  {prox.dono === 'operador' ? 'sua vez' : 'Claude'} · {prox.tamanho} · {prox.nivel ?? 'requisito'}
1156                </Text>
1157              </Box>
1158            )
1159          })()
1160        ) : null}
1161      </Box>
1162    )
1163
1164    // Os outros workspaces do herdr: uma linha por workspace, o título do agente com o ponteiro em cima.
1165    const vs = await read($, vizinhos)
1166    const vizinhosBloco = vs.length ? (
1167      <Box flexDirection="column" marginTop={1}>
1168        <Text weight="bold">OUTROS WORKSPACES</Text>
1169        {vs.map(v => {
1170          const est = ESTADOS[estadoDoHerdr(v.estado)]
1171          return (
1172            <Box key={`viz-${v.workspace}`} flexDirection="column">
1173              <Text>
1174                <Text color={est.cor}>{est.glifo}</Text> {v.rotulo} <Text color={est.cor}>{est.rotulo}</Text>
1175              </Text>
1176              {v.agentes.length ? (
1177                <Box display="none" hover={{ display: 'flex' }} flexDirection="column" paddingLeft={2}>
1178                  {v.agentes.map(a => (
1179                    <Text key={`viz-${a.pane}`} dimColor wrap="wrap">
1180                      {a.pane} · {ESTADOS[estadoDoHerdr(a.estado)].rotulo}
1181                      {a.titulo ? ` · ${a.titulo}` : ''}
1182                    </Text>
1183                  ))}
1184                </Box>
1185              ) : null}
1186            </Box>
1187          )
1188        })}
1189      </Box>
1190    ) : null
1191
1192    // O caderno: a porta do operador durante a execução (propósito e contrato em caderno.ts).
1193    const notas = await read($, caderno)
1194    const tipo = await read($, tipoNota)
1195    const regra = TIPOS.find(t => t.tipo === tipo) ?? TIPOS[0]!
1196    const linhaNota = (n: Nota) => {
1197      const d = GLIFO_DECISAO[n.triagem?.decisao ?? 'aguardando']
1198      const prefixo = `${d.glifo} ${n.tipo} `
1199      const { curto } = encurtar(n.texto, largura - prefixo.length)
1200      return (
hooks/objetivo.ts 377 lines
1import type { Disparo, Goal, GoalItem, ItemRoadmap, Nivel, Owner, Size, Snapshot } from '../types'
2
3// Peso de cada tamanho na projeção; o custo por unidade vem do histórico (ou do padrão abaixo).
4export const PESO: Record<Size, number> = { P: 1, M: 2, G: 4 }
5// Sem histórico: tokens de janela por unidade de peso (uma pendência pequena).
6export const UNIDADE_PADRAO = 6000
7const AMOSTRAS = 8
8
9// Ordem de execução: o que trava vem antes do que a entrega precisa, que vem antes do que pode esperar.
10export const NIVEIS: Nivel[] = ['bloqueante', 'requisito', 'melhoria']
11const ehNivel = (n: unknown): n is Nivel => NIVEIS.includes(n as Nivel)
12export const nivelDe = (i: GoalItem): Nivel => i.nivel ?? 'requisito'
13
14/** Pendências na ordem de execução: nível, depois a ordem em que entraram. */
15export function ordenar(itens: GoalItem[]): GoalItem[] {
16  return [...itens].sort((a, b) => NIVEIS.indexOf(nivelDe(a)) - NIVEIS.indexOf(nivelDe(b)) || a.id - b.id)
17}
18
19/** A próxima pendência a fazer, de qualquer dono. */
20export function proxima(g: Goal): GoalItem | undefined {
21  return ordenar(g.itens.filter(i => !i.feito))[0]
22}
23
24/** Etiqueta do nível na linha e no resumo; requisito é o comum e não leva etiqueta. */
25export const etiqueta = (i: GoalItem) => (nivelDe(i) === 'requisito' ? '' : `[${nivelDe(i)}] `)
26
27export type Acao = {
28  acao: 'definir' | 'adicionar' | 'concluir' | 'reabrir' | 'priorizar' | 'encerrar' | 'ver' | 'retirar' | 'disparo'
29  titulo?: string
30  conclusao?: string
31  itens?: { texto: string; dono?: Owner; tamanho?: Size; nivel?: Nivel }[]
32  ids?: number[]
33  nivel?: Nivel
34  /** Para disparo. */
35  modo?: Disparo['modo']
36  meio?: Disparo['meio']
37  harness?: string
38}
39
40export const MODOS: Disparo['modo'][] = ['paralelo', 'fila']
41export const MEIOS: Disparo['meio'][] = ['sessao', 'subagente', 'workflow', 'herdr']
42
43export type Projecao = {
44  abertasClaude: number
45  abertasOperador: number
46  feitas: number
47  total: number
48  /** Tokens de janela por unidade de peso (média do histórico ou padrão). */
49  unidade: number
50  comHistorico: boolean
51  /** Tokens estimados para as pendências do Claude que faltam. */
52  necessario: number
53  /** Tokens livres antes da reserva de compactação. */
54  livre: number
55  estado: 'cabe' | 'apertado' | 'nao-cabe' | 'sem-dados'
56}
57
58/** Tokens em uso na janela (tudo menos livre e reserva). */
59export function usado(snap: Snapshot | null): number {
60  if (!snap) return 0
61  return snap.segments.reduce((a, s) => (s.key === 'livre' || s.key === 'reserva' ? a : a + s.tokens), 0)
62}
63
64export function livre(snap: Snapshot | null): number {
65  return snap?.segments.find(s => s.key === 'livre')?.tokens ?? 0
66}
67
68function novosItens(base: number, itens: Acao['itens'], donoPadrao: Owner): GoalItem[] {
69  return (itens ?? [])
70    .filter(i => i.texto && i.texto.trim())
71    .map((i, k) => ({
72      id: base + k + 1,
73      texto: i.texto.trim(),
74      dono: i.dono === 'operador' ? 'operador' : i.dono === 'claude' ? 'claude' : donoPadrao,
75      tamanho: i.tamanho === 'P' || i.tamanho === 'G' ? i.tamanho : 'M',
76      feito: false,
77      nivel: ehNivel(i.nivel) ? i.nivel : 'requisito',
78    }))
79}
80
81/** Aplica uma ação ao objetivo. `uso` é o contexto em uso agora; `turno`, o contador de turnos. */
82export function aplicar(
83  atual: Goal | null,
84  a: Acao,
85  origem: Owner,
86  uso: number,
87  turno: number,
88): { goal: Goal | null; erro?: string } {
89  if (a.acao === 'ver') return { goal: atual }
90  if (a.acao === 'encerrar') return { goal: null }
91  if (a.acao === 'definir') {
92    if (!a.titulo?.trim()) return { goal: atual, erro: 'definir precisa de titulo.' }
93    return {
94      goal: {
95        titulo: a.titulo.trim(),
96        conclusao: a.conclusao?.trim() || '',
97        origem,
98        itens: novosItens(0, a.itens, 'claude'),
99        marco: uso,
100        consumo: atual?.consumo ?? [],
101        atualizadoTurno: turno,
102        aviso: null,
103      },
104    }
105  }
106  if (!atual) return { goal: null, erro: 'nenhum objetivo ativo: use definir primeiro.' }
107  const g: Goal = { ...atual, atualizadoTurno: turno }
108  if (a.titulo?.trim()) g.titulo = a.titulo.trim()
109  if (a.conclusao?.trim()) g.conclusao = a.conclusao.trim()
110
111  if (a.acao === 'adicionar') {
112    const base = g.itens.reduce((m, i) => Math.max(m, i.id), 0)
113    g.itens = [...g.itens, ...novosItens(base, a.itens, 'claude')]
114    return { goal: g }
115  }
116  const ids = new Set(a.ids ?? [])
117  if (ids.size === 0) return { goal: atual, erro: `${a.acao} precisa de ids.` }
118  const desconhecidos = [...ids].filter(id => !g.itens.some(i => i.id === id))
119  if (desconhecidos.length) return { goal: atual, erro: `ids inexistentes: ${desconhecidos.join(', ')}.` }
120
121  if (a.acao === 'priorizar') {
122    if (!ehNivel(a.nivel)) return { goal: atual, erro: `priorizar precisa de nivel (${NIVEIS.join(' | ')}).` }
123    g.itens = g.itens.map(i => (ids.has(i.id) ? { ...i, nivel: a.nivel } : i))
124    return { goal: g }
125  }
126  // disparo: como a frente roda (validação do disparo, ACIONAMENTO.md); a cobrança na linha some quando todas têm.
127  if (a.acao === 'disparo') {
128    if (!MODOS.includes(a.modo as Disparo['modo'])) return { goal: atual, erro: `disparo precisa de modo (${MODOS.join(' | ')}).` }
129    if (!MEIOS.includes(a.meio as Disparo['meio'])) return { goal: atual, erro: `disparo precisa de meio (${MEIOS.join(' | ')}).` }
130    const harness = a.harness?.trim()
131    const d: Disparo = { modo: a.modo!, meio: a.meio!, ...(harness ? { harness } : {}) }
132    g.itens = g.itens.map(i => (ids.has(i.id) ? { ...i, disparo: d } : i))
133    return { goal: g }
134  }
135  // retirar: a pendência sai do objetivo (o roadmap a guarda antes, pela acao "adiar"); não é concluir.
136  if (a.acao === 'retirar') {
137    g.itens = g.itens.filter(i => !ids.has(i.id))
138    return { goal: g }
139  }
140  if (a.acao === 'reabrir') {
141    g.itens = g.itens.map(i => (ids.has(i.id) ? { ...i, feito: false } : i))
142    return { goal: g }
143  }
144  // concluir: o contexto gasto desde o último marco vira uma amostra por unidade de peso.
145  const fechando = g.itens.filter(i => ids.has(i.id) && !i.feito)
146  const pesoClaude = fechando.filter(i => i.dono === 'claude').reduce((s, i) => s + PESO[i.tamanho], 0)
147  const delta = uso - g.marco
148  if (pesoClaude > 0 && delta > 0) g.consumo = [...g.consumo, Math.round(delta / pesoClaude)].slice(-AMOSTRAS)
149  if (pesoClaude > 0) g.marco = uso
150  g.itens = g.itens.map(i => (ids.has(i.id) ? { ...i, feito: true } : i))
151  return { goal: g }
152}
153
154/**
155 * O que vale ao carregar a pasta `raiz` na sessão. `salvo` é o do store; `naSessao`, o do $.state.
156 * Na primeira carga (sessão nova ou reload do módulo) um objetivo que só está no $.state vale e passa
157 * a ser gravado; noutra pasta (/cd) vale o do store. O que vem do store recomeça marco, turno e aviso.
158 */
159export function aoCarregar(
160  primeiraCarga: boolean,
161  salvo: Goal | null,
162  naSessao: Goal | null,
163  uso: number,
164  turno: number,
165): { goal: Goal | null; gravar: boolean } {
166  if (primeiraCarga && !salvo && naSessao) return { goal: naSessao, gravar: true }
167  return { goal: salvo ? { ...salvo, marco: uso, atualizadoTurno: turno, aviso: null } : null, gravar: false }
168}
169
170/** Depois de uma compactação o uso cai: o próximo delta parte do uso novo. */
171export function aposCompactar(g: Goal | null, uso: number): Goal | null {
172  return g ? { ...g, marco: uso, aviso: null } : g
173}
174
175export function projetar(g: Goal, snap: Snapshot | null): Projecao {
176  const abertas = g.itens.filter(i => !i.feito)
177  const abertasClaude = abertas.filter(i => i.dono === 'claude')
178  const comHistorico = g.consumo.length >= 2
179  const unidade = comHistorico ? Math.round(g.consumo.reduce((a, b) => a + b, 0) / g.consumo.length) : UNIDADE_PADRAO
180  const necessario = abertasClaude.reduce((s, i) => s + PESO[i.tamanho] * unidade, 0)
181  const folga = livre(snap)
182  const estado = !snap
183    ? 'sem-dados'
184    : necessario <= folga * 0.8
185      ? 'cabe'
186      : necessario <= folga
187        ? 'apertado'
188        : 'nao-cabe'
189  return {
190    abertasClaude: abertasClaude.length,
191    abertasOperador: abertas.length - abertasClaude.length,
192    feitas: g.itens.length - abertas.length,
193    total: g.itens.length,
194    unidade,
195    comHistorico,
196    necessario,
197    livre: folga,
198    estado,
199  }
200}
201
202const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
203
204/**
205 * A checagem de alinhamento, devolvida ao definir ou acrescentar pendências: a conclusão é o efeito que se quer, e
206 * cada pendência precisa aproximar dele. Nasceu de uma falha real (lançamento do claude-grounds, 14/10/2026): as
207 * pendências saíram do texto do pedido (artefatos) e não do efeito, e a entrega repetiu o molde errado duas vezes.
208 */
209export function alinhamento(g: Goal): string {
210  if (!g.conclusao?.trim()) return 'Alinhamento: sem conclusão. Antes de começar, defina o passo que encerra pelo efeito que se quer (para quem, o que muda).'
211  return (
212    `Alinhamento: a conclusão "${g.conclusao}" descreve o efeito que se quer, e não só um artefato entregue? ` +
213    'Cada pendência aproxima dela, e pelo menos uma mostra que o efeito aconteceu? ' +
214    'Se alguma saiu do texto do pedido e não do efeito, reescreva antes de começar.'
215  )
216}
217
218const JANELA = { cabe: ' · janela cabe', apertado: ' · janela apertada', 'nao-cabe': ' · janela não cabe', 'sem-dados': '' } as const
219const curto = (t: string) => (t.length > 60 ? `${t.slice(0, 59)}…` : t)
220
221/**
222 * Linha que acompanha cada prompt: título, progresso, a próxima com o dono e a janela. Vai em todo pedido, então
223 * fica curta: as listas e a conclusão saem pela ferramenta (acao "ver") e nas respostas de definir e concluir.
224 * `completa` (objetivo recém-definido ou início de sessão) acrescenta uma vez a conclusão e como marcar.
225 */
226/** As frentes abertas do Claude sem disparo declarado, quando há duas ou mais abertas; com uma só, nada a validar. */
227export function disparoPendente(g: Goal): GoalItem[] {
228  const minhas = ordenar(g.itens.filter(i => !i.feito && i.dono === 'claude'))
229  return minhas.length >= 2 ? minhas.filter(i => !i.disparo) : []
230}
231
232const NOME_MEIO: Record<Disparo['meio'], string> = { sessao: 'sessão', subagente: 'subagente', workflow: 'workflow', herdr: 'herdr' }
233/** O disparo em poucas colunas: ⇉ em paralelo, → em fila, o meio e o harness. */
234export const textoDisparo = (d: Disparo) => `${d.modo === 'paralelo' ? '⇉' : '→'} ${NOME_MEIO[d.meio]}${d.harness ? `·${d.harness}` : ''}`
235
236export function linhaContexto(g: Goal | null, snap: Snapshot | null, tool: string, completa = false): string {
237  if (!g) {
238    return `[objetivo] Nenhum objetivo ativo. Se este pedido for uma tarefa com etapas, defina-o com ${tool} (acao "definir": titulo, conclusao = o passo que encerra, itens com dono claude|operador e tamanho P|M|G).`
239  }
240  const p = projetar(g, snap)
241  const prox = proxima(g)
242  const temConclusao = !!g.conclusao?.trim()
243  const linha =
244    `[objetivo] ${g.titulo} · ${p.feitas}/${p.total}` +
245    (prox ? ` · próxima #${prox.id} ${etiqueta(prox)}${curto(prox.texto)} (${prox.dono})` : ' · tudo feito') +
246    JANELA[p.estado] +
247    (() => {
248      const falta = disparoPendente(g)
249      return falta.length
250        ? ` · declarar disparo: ${falta.map(i => `#${i.id}`).join(', ')}`
251        : ''
252    })() +
253    (temConclusao ? ' · antes de agir: isso aproxima da conclusão?' : '')
254  if (!completa) return linha
255  return (
256    linha +
257    (temConclusao ? ` Conclui quando: ${g.conclusao}.` : '') +
258    ` Ao concluir cada pendência, marque com ${tool} (acao "concluir", ids); a lista inteira sai com acao "ver"; ` +
259    'se uma trava outra ou pode esperar, acao "priorizar" (ids, nivel); ao fechar o ciclo, diga ao operador o que ainda é dele.'
260  )
261}
262
263// Comandos que publicam: o que sai daqui chega a outras pessoas. Lista curta, por palavra inteira do comando.
264const PUBLICA = [
265  /\bgit\s+push\b/,
266  /\bnpm\s+publish\b/,
267  /\bgh\s+release\b/,
268  /(^|[\s;&|(])(\S*\/)?(deploy|publicar|promover)(\.sh|\.py)?(?=$|[\s;&|)])/,
269  /(^|[\s;&|(])(rsync|scp)\s/,
270  // cp/mv cujo destino (o último argumento) cai em output/espelhos/
271  /(^|[\s;&|(])(cp|mv)\s[^;&|]*\s\S*output\/espelhos\/\S*\s*($|[;&|])/,
272]
273/**
274 * O comando sem o texto que ele só carrega: corpo de heredoc e string entre aspas com espaço (prosa, código de
275 * teste) somem; a string sem espaço (um caminho entre aspas) fica sem as aspas. Um `git push` escrito num teste
276 * não é publicar (achado na própria rodada da 0.10.0).
277 */
278export function semTexto(comando: string): string {
279  return comando
280    .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, ' ')
281    .replace(/'([^']*)'|"((?:[^"\\]|\\.)*)"/g, (_m, a?: string, b?: string) => {
282      const dentro = a ?? b ?? ''
283      return /\s/.test(dentro) ? ' ' : dentro
284    })
285}
286export const publica = (comando: string) => PUBLICA.some(r => r.test(semTexto(comando)))
287
288/** A pendência do Claude aberta que uma publicação atropelaria: bloqueante, ou de verificar, provar ou capturar. */
289export function verificacaoAberta(g: Goal | null): GoalItem | undefined {
290  if (!g) return undefined
291  return ordenar(g.itens.filter(i => !i.feito && i.dono === 'claude')).find(i => nivelDe(i) === 'bloqueante' || /verifica|provar|captura/i.test(i.texto))
292}
293
294/** Nota ao modelo numa chamada que publica com a verificação aberta; vazia quando não é o caso. Nunca trava. */
295export function notaPublicacao(g: Goal | null, comando: string): string | undefined {
296  if (!publica(comando)) return undefined
297  const i = verificacaoAberta(g)
298  if (!i) return undefined
299  return `[objetivo] esta ação publica e a pendência #${i.id} "${curto(i.texto)}" está aberta: conclua a verificação antes, ou diga ao operador por que não`
300}
301
302/** Aviso ao modelo quando a projeção piora; devolve o novo nível para não repetir. */
303export function avisoJanela(g: Goal, snap: Snapshot | null): { nivel: Goal['aviso']; texto?: string } {
304  const p = projetar(g, snap)
305  const nivel = p.estado === 'apertado' || p.estado === 'nao-cabe' ? p.estado : null
306  if (!nivel || nivel === g.aviso) return { nivel }
307  const texto =
308    `[objetivo] Aviso de janela: faltam ${p.abertasClaude} pendência(s) suas, ~${k(p.necessario)} tokens estimados, ` +
309    `e restam ${k(p.livre)} livres antes da compactação (${nivel === 'nao-cabe' ? 'não cabe' : 'apertado'}). ` +
310    `Antes de seguir: registre o estado (objetivo atualizado, notas no vault), delegue partes a subagentes ou combine com o operador uma compactação.`
311  return { nivel, texto }
312}
313
314/** Resposta da ferramenta: o estado inteiro, curto. */
315export function resumo(g: Goal | null, snap: Snapshot | null): string {
316  if (!g) return 'Nenhum objetivo ativo.'
317  const p = projetar(g, snap)
318  const linhas = ordenar(g.itens).map(i => `${i.feito ? '[x]' : '[ ]'} #${i.id} (${i.dono}, ${i.tamanho}) ${etiqueta(i)}${i.texto}${i.disparo ? ` ${textoDisparo(i.disparo)}` : ''}`)
319  return [
320    `Objetivo: ${g.titulo}${g.conclusao ? ` · conclui quando: ${g.conclusao}` : ''}`,
321    ...linhas,
322    `${p.feitas}/${p.total} feitas · Claude ${p.abertasClaude} abertas · operador ${p.abertasOperador} · ` +
323      `~${k(p.unidade)}/unidade ${p.comHistorico ? '(histórico)' : '(padrão, sem histórico)'} · ` +
324      `precisa ~${k(p.necessario)} · livre ${k(p.livre)} · ${p.estado}`,
325  ].join('\n')
326}
327
328/** Interpreta `/objetivo <args>` do operador. */
329export function comando(args: string): Acao | 'painel' | 'auto-on' | 'auto-off' {
330  const t = args.trim()
331  if (!t) return 'painel'
332  const [cmd, ...resto] = t.split(/\s+/)
333  const c = (cmd ?? '').toLowerCase()
334  if (c === 'auto' && (resto[0] === 'on' || resto[0] === 'off') && resto.length === 1) return resto[0] === 'on' ? 'auto-on' : 'auto-off'
335  if (c === 'encerrar' || c === 'limpar') return { acao: 'encerrar' }
336  const ids = resto.map(Number).filter(n => Number.isInteger(n) && n > 0)
337  if ((c === 'feito' || c === 'concluir') && ids.length) return { acao: 'concluir', ids }
338  if (c === 'reabrir' && ids.length) return { acao: 'reabrir', ids }
339  if (ehNivel(c) && ids.length) return { acao: 'priorizar', ids, nivel: c }
340  if (c === 'meu' || c === 'minha') return { acao: 'adicionar', itens: [{ texto: resto.join(' '), dono: 'operador' }] }
341  if (c === 'add' || c === 'adicionar') return { acao: 'adicionar', itens: [{ texto: resto.join(' '), dono: 'claude' }] }
342  // Qualquer outro texto define um objetivo novo; "::" separa o passo que conclui.
343  const [titulo, conclusao] = t.split('::').map(s => s.trim())
344  return { acao: 'definir', titulo, conclusao }
345}
346
347// Pedir licença para o que já foi aprovado: o ciclo para à toa e o operador precisa voltar só para dizer "sim"
348// (16/10: "Sigo com R9 e R10?" com os dois no roadmap em agora; a memória não bastou, a regra virou código).
349const LICENCA =
350  /(\b(sigo|prossigo|continuo|come[çc]o|posso (seguir|continuar|come[çc]ar|prosseguir)|devo (seguir|continuar|prosseguir|come[çc]ar)|quer que eu|deseja que eu|prefere que eu)\b[^?]*\?$)|(\b(sigo|prossigo|continuo)\b.*\bno pr[óo]ximo turno\b[^?]*\.?$)/i
351
352/** A última frase da resposta, quando ela pede licença para seguir; nada quando a resposta termina de outro jeito. */
353export function pedeLicenca(texto: string): string | undefined {
354  const paragrafo = texto.trim().split(/\n\s*\n/).pop()?.trim() ?? ''
355  const frase = paragrafo.split(/(?<=[.!?])\s+(?=\S)/).pop()?.trim() ?? ''
356  return frase && LICENCA.test(frase) ? frase : undefined
357}
358
359/**
360 * O motivo para não deixar o turno acabar: a resposta pede licença e há trabalho do Claude já aprovado (pendência
361 * aberta dele no objetivo; sem nenhuma, o roadmap em agora). Nada aprovado, a pergunta é legítima.
362 */
363export function bloqueioDeLicenca(texto: string, g: Goal | null, roadmap: ItemRoadmap[]): string | undefined {
364  const frase = pedeLicenca(texto)
365  if (!frase) return undefined
366  const minha = g ? ordenar(g.itens.filter(i => !i.feito && i.dono === 'claude'))[0] : undefined
367  // Só o aprovado pelo operador (R18): proposta do Claude em agora não é licença para seguir.
368  const agora = roadmap.filter(i => i.estado === 'aberto' && i.horizonte === 'agora' && i.aprovado)
369  const aprovado = minha ? `#${minha.id} ${minha.texto}` : agora.length ? agora.map(i => `R${i.n} ${i.texto}`).join('; ') : undefined
370  if (!aprovado) return undefined
371  return (
372    `[objetivo] Você terminou pedindo licença ("${frase}") para trabalho já aprovado: ${curto(aprovado)}. ` +
373    'Não peça: siga agora (no roadmap, promova e defina o objetivo neste turno). ' +
374    'Se houver uma decisão genuína do operador, pergunte pela ferramenta AskUserQuestion, não em texto no fim da resposta.'
375  )
376}
377
hooks/cache.ts 52 lines
1// Taxa de acerto do cache de prompt da sessão, medida pelo próprio mod: a prova de que o fluxo se paga.
2// Fonte: o transcript (semente, uma vez por sessão) e o uso de cada turno da conversa principal (depois).
3
4import type { Cache } from '../types'
5
6export type { Cache }
7export type Uso = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
8
9export const CACHE_VAZIO: Cache = { lidos: 0, total: 0, n: 0 }
10
11const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
12
13/** Soma o uso de uma resposta (ou de um turno inteiro, que já vem somado). */
14export function somar(acc: Cache, u: Uso | null | undefined): Cache {
15  if (!u) return acc
16  const lidos = num(u.cache_read_input_tokens)
17  const total = num(u.input_tokens) + lidos + num(u.cache_creation_input_tokens)
18  if (total <= 0) return acc
19  return { ...acc, lidos: acc.lidos + lidos, total: total + acc.total, n: acc.n + 1 }
20}
21
22/**
23 * Soma as respostas da conversa principal de um trecho do transcript (JSONL). Ignora subagentes (isSidechain),
24 * linhas sem uso e linhas quebradas; um trecho por vez dá o mesmo que o arquivo inteiro.
25 */
26export function doTranscript(texto: string, acc: Cache = CACHE_VAZIO): Cache {
27  for (const linha of texto.split('\n')) {
28    if (!linha.trim()) continue
29    let d: { type?: string; isSidechain?: boolean; message?: { id?: string; usage?: Uso } }
30    try {
31      d = JSON.parse(linha)
32    } catch {
33      continue
34    }
35    if (d?.type !== 'assistant' || d.isSidechain || !d.message?.usage) continue
36    const id = d.message.id
37    if (id && id === acc.ultimoId) continue
38    acc = { ...somar(acc, d.message.usage), ultimoId: id }
39  }
40  return acc
41}
42
43/** Percentual de acerto (0-100), ou null sem nada medido. */
44export function taxa(acc: Cache | null): number | null {
45  if (!acc || acc.total <= 0) return null
46  return Math.round((acc.lidos / acc.total) * 100)
47}
48
49/** Caminho do transcript da sessão: a pasta do projeto com tudo que não é letra ou número trocado por "-". */
50export const caminhoTranscript = (home: string, cwd: string, id: string) =>
51  `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
52
hooks/segundo-plano.ts 81 lines
1// Trabalho em segundo plano que ficou mudo. Nasceu de uma falha real (numa sessão paralela, 15/10/2026): uma verificação
2// longa foi para o segundo plano, o turno acabou, a saída ficou em 0 bytes (stdout do Python em buffer) e o agente
3// esperou 37 minutos até o operador perguntar "concluiu?". Só observa: nunca trava uma chamada.
4
5import type { SegundoPlano, Tarefa } from '../types'
6
7export type { SegundoPlano, Tarefa }
8
9export const SP_VAZIO: SegundoPlano = { tarefas: [], voltaEm: null, fimTurno: null, cronsAtivos: false }
10/** Minutos sem a saída mudar para a tarefa contar como muda. */
11export const MINUTOS_SEM_SAIDA = 5
12/** Ferramentas que trazem o agente de volta sem o operador pedir. */
13export const FERRAMENTAS_DE_VOLTA = ['ScheduleWakeup', 'Monitor', 'CronCreate']
14
15export function registrarTarefa(sp: SegundoPlano, t: Tarefa): SegundoPlano {
16  return { ...sp, tarefas: [...sp.tarefas.filter(x => x.id !== t.id), t] }
17}
18
19export function concluirTarefa(sp: SegundoPlano, id: string): SegundoPlano {
20  return sp.tarefas.some(t => t.id === id) ? { ...sp, tarefas: sp.tarefas.filter(t => t.id !== id) } : sp
21}
22
23/**
24 * Fim do turno da conversa principal. `emCurso` é o que o Stop lista em andamento (ausente, nada se sabe): some a
25 * tarefa que não está lá. Se há shells listados e nenhum id bate com os nossos, os ids não são da mesma família e
26 * nada se tira, para não calar um alerta verdadeiro.
27 */
28export function fimDeTurno(sp: SegundoPlano, agora: number, emCurso?: readonly { id: string; type?: string }[], crons?: number): SegundoPlano {
29  const ids = emCurso?.map(t => t.id)
30  const desencontro = !!emCurso?.some(t => t.type === 'shell') && !sp.tarefas.some(t => ids!.includes(t.id))
31  const tarefas = ids && !desencontro ? sp.tarefas.filter(t => ids.includes(t.id)) : sp.tarefas
32  return { ...sp, tarefas, fimTurno: agora, cronsAtivos: crons === undefined ? sp.cronsAtivos : crons > 0 }
33}
34
35/** O id e o estado de uma notificação de tarefa (`<task-id>`, `<status>`), ou null. */
36export function notificacao(texto: string): { id: string; status: string } | null {
37  const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(texto)?.[1]
38  if (!id) return null
39  return { id, status: /<status>\s*([^<\s]+)\s*<\/status>/.exec(texto)?.[1] ?? '' }
40}
41
42/** O arquivo de saída que o resultado do Bash em segundo plano anuncia. */
43export function caminhoSaida(texto: string | undefined): string | undefined {
44  return texto ? /(\/[^\s'"`]+\.output)\b/.exec(texto)?.[1] : undefined
45}
46
47export type Alerta = { tarefa: Tarefa; semSaidaMin: number | null; semVolta: boolean }
48
49/** Tarefas em curso que pedem atenção: saída parada há `minutos` ou mais, ou turno encerrado sem volta agendada. */
50export function alertas(sp: SegundoPlano, agora: number, minutos = MINUTOS_SEM_SAIDA): Alerta[] {
51  const r: Alerta[] = []
52  for (const t of sp.tarefas) {
53    const parado = t.saida ? Math.floor((agora - Math.max(t.mtime ?? t.inicio, t.inicio)) / 60000) : 0
54    const semSaidaMin = parado >= minutos ? parado : null
55    const voltou = sp.cronsAtivos || (sp.voltaEm !== null && sp.voltaEm >= t.inicio)
56    const semVolta = !voltou && sp.fimTurno !== null && sp.fimTurno >= t.inicio
57    if (semSaidaMin !== null || semVolta) r.push({ tarefa: t, semSaidaMin, semVolta })
58  }
59  return r
60}
61
62const curta = (t: string) => (t.length > 40 ? `${t.slice(0, 39)}…` : t)
63
64/** O pedaço da linha ao modelo: curto, só com o primeiro alerta (e quantos mais houver). */
65export function avisoAoModelo(a: Alerta[]): string {
66  const p = a[0]
67  if (!p) return ''
68  const partes = [p.semSaidaMin !== null ? `sem saída há ${p.semSaidaMin} min` : '', p.semVolta ? 'sem volta agendada' : ''].filter(Boolean)
69  return ` · ⚠ tarefa em segundo plano ${partes.join(', ')}${a.length > 1 ? ` (+${a.length - 1})` : ''}`
70}
71
72/** Uma linha do painel por tarefa em curso. */
73export function linhasDoPainel(sp: SegundoPlano, agora: number): string[] {
74  const a = alertas(sp, agora)
75  return sp.tarefas.map(t => {
76    const al = a.find(x => x.tarefa.id === t.id)
77    const partes = [al?.semSaidaMin != null ? `sem saída há ${al.semSaidaMin} min` : '', al?.semVolta ? 'nada agendado para voltar' : ''].filter(Boolean)
78    return `segundo plano: ${curta(t.descricao)} · ${partes.length ? partes.join(' · ') : 'em curso'}`
79  })
80}
81
hooks/caderno.ts 149 lines
1// O caderno do operador: a porta por onde quem conduz o trabalho fala com o Claude durante a execução, sem
2// interromper. Não é a iteração do ciclo (o objetivo e as pendências que o Claude executa): é entrada externa a
3// ele. Cada nota funciona como um advisor e um adversário: um contraponto ao raciocínio do Claude, que precisa
4// ser avaliado contra o plano e respondido (aceita, recusada com motivo, muda o plano, ou só registrada).
5//
6// Contrato (versão 1): ~/.claude/caderno/<raiz do projeto, encodeURIComponent>.jsonl, uma linha JSON por evento,
7// só acréscimo. Evento `nota` (do operador) e `triagem` (do Claude), ligados pelo `id`. Nada é reescrito: a nota
8// e a resposta ficam como foram dadas. Quem lê ignora campos que não conhece e linhas que não entende, para que
9// versões novas, outras sessões e outros agentes (via herdr) escrevam no mesmo arquivo sem quebrar ninguém.
10import type { Decisao, EventoNota, EventoTriagem, Nota, TipoNota } from '../types'
11
12export const VERSAO_CADERNO = 1
13
14// A regra de uma linha, pelo compromisso (GTD, Kanban upstream, Shape Up): vai ser feito neste ciclo? → agora.
15// Quero que chegue lá, mas não neste ciclo → depois (roadmap). Nem isso → nota. Link, busca ou imagem a serviço de
16// algo → referência. Na dúvida, nota: o Claude propõe o tipo na triagem. A força sobre o Claude vem do tipo: nota
17// informa sem mudar o rumo; agora entra na fila sem abandonar o que está em curso; depois só é registrada.
18export const TIPOS: { tipo: TipoNota; rotulo: string; regra: string }[] = [
19  { tipo: 'nota', rotulo: 'nota', regra: 'observação, ideia ou dúvida: o Claude avalia e responde, sem mudar de rumo' },
20  { tipo: 'agora', rotulo: 'agora', regra: 'vai ser feito neste ciclo: entra na fila sem largar o que está em curso' },
21  { tipo: 'depois', rotulo: 'depois', regra: 'quero que chegue lá, mas não neste ciclo: vai ao roadmap' },
22  { tipo: 'referencia', rotulo: 'referência', regra: 'link, busca ou imagem a serviço de uma pendência (#id) ou do trabalho atual' },
23]
24const NOMES_TIPO = TIPOS.map(t => t.tipo)
25export const DECISOES: Decisao[] = ['aceita', 'recusada', 'muda-plano', 'registrada']
26
27export const PROPOSITO =
28  'Caderno do operador: entrada de quem conduz o trabalho, fora da iteração do ciclo. Cada nota é um advisor e um ' +
29  'adversário do seu raciocínio. Antes de decidir: contradiz uma premissa sua? muda a prioridade? revela um risco ' +
30  'que você não via? dá para verificar já (se dá, verifique)? qual o melhor argumento contra a nota, e contra a sua ' +
31  'decisão? Aceitar só porque veio do operador é tão errado quanto recusar só porque o plano já estava feito. ' +
32  'Responda com acao "triar" (nota, decisao aceita|recusada|muda-plano|registrada, motivo verificável, destino, contraponto).'
33
34/** `#12 texto` liga a nota à pendência 12; sem prefixo, à próxima pendência no momento da escrita. */
35export function novaNota(texto: string, tipo: TipoNota, em: string, sufixo: string, refPadrao?: string): EventoNota | null {
36  const t = texto.replace(/\s+/g, ' ').trim()
37  const m = /^#(\d+)\b\s*(.*)$/.exec(t)
38  const corpo = (m ? m[2]! : t).trim()
39  if (!corpo) return null
40  const ref = m ? `#${m[1]}` : refPadrao
41  return { v: VERSAO_CADERNO, ev: 'nota', id: `n-${em.replace(/\D/g, '').slice(0, 14)}-${sufixo}`, em, autor: 'operador', tipo, texto: corpo, ...(ref ? { ref } : {}) }
42}
43
44export function triagem(id: string, decisao: Decisao, motivo: string, em: string, destino?: string, contraponto?: string): EventoTriagem {
45  return {
46    v: VERSAO_CADERNO,
47    ev: 'triagem',
48    id,
49    em,
50    autor: 'claude',
51    decisao,
52    motivo: motivo.trim(),
53    ...(destino?.trim() ? { destino: destino.trim() } : {}),
54    ...(contraponto?.trim() ? { contraponto: contraponto.trim() } : {}),
55  }
56}
57
58export const linha = (ev: EventoNota | EventoTriagem) => `${JSON.stringify(ev)}\n`
59
60/** As notas do arquivo, na ordem em que foram escritas, cada uma com a triagem mais recente. Tolerante a lixo. */
61export function lerCaderno(texto: string): Nota[] {
62  const notas = new Map<string, Nota>()
63  for (const bruta of texto.split('\n')) {
64    if (!bruta.trim()) continue
65    let o: Record<string, unknown>
66    try {
67      o = JSON.parse(bruta)
68    } catch {
69      continue
70    }
71    if (!o || typeof o !== 'object' || typeof o.v !== 'number' || o.v < 1 || typeof o.id !== 'string' || !o.id) continue
72    if (o.ev === 'nota') {
73      if (typeof o.texto !== 'string' || !o.texto || notas.has(o.id)) continue
74      // 'ideia' era o nome da nota antes da pesquisa de 15/10; tipo desconhecido (de uma versão nova) também vira nota.
75      const tipo = NOMES_TIPO.includes(o.tipo as TipoNota) ? (o.tipo as TipoNota) : 'nota'
76      notas.set(o.id, {
77        v: o.v,
78        ev: 'nota',
79        id: o.id,
80        em: typeof o.em === 'string' ? o.em : '',
81        autor: 'operador',
82        tipo,
83        texto: o.texto,
84        ...(typeof o.ref === 'string' && o.ref ? { ref: o.ref } : {}),
85      })
86    } else if (o.ev === 'triagem') {
87      const n = notas.get(o.id)
88      if (!n || !DECISOES.includes(o.decisao as Decisao) || typeof o.motivo !== 'string') continue
89      const texto = (k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined)
90      n.triagem = triagem(o.id, o.decisao as Decisao, o.motivo, texto('em') ?? '', texto('destino'), texto('contraponto'))
91    }
92  }
93  return [...notas.values()]
94}
95
96export const semResposta = (notas: Nota[]) => notas.filter(n => !n.triagem)
97
98/** Há quanto tempo, em palavras curtas. */
99export function idade(em: string, agora: number): string {
100  const ms = agora - Date.parse(em)
101  if (!Number.isFinite(ms) || ms < 60000) return 'agora há pouco'
102  const min = Math.round(ms / 60000)
103  return min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`
104}
105
106/**
107 * O texto que leva ao Claude as notas ainda não entregues: com a idade, o aviso de que o operador não viu o que
108 * veio depois e o limite de autoridade (uma nota não responde a pergunta nem aprova ação; salvo "agora", a tarefa
109 * segue). O propósito vai junto na primeira entrega da sessão.
110 */
111export function entrega(notas: Nota[], comProposito: boolean, agora: number): string {
112  if (!notas.length) return ''
113  const corpo = notas.map(n => `${n.id} (${n.tipo}${n.ref ? `, sobre ${n.ref}` : ''}, ${idade(n.em, agora)}): ${n.texto}`).join(' | ')
114  const limite = 'O operador escreveu sem ver as saídas seguintes. Isto não responde a pergunta sua nem vale como aprovação; salvo "agora", continue a tarefa atual.'
115  return `[caderno] ${notas.length === 1 ? 'Nota nova' : `${notas.length} notas novas`} do operador: ${corpo} — ${limite}${comProposito ? ` ${PROPOSITO}` : ' Responda com acao "triar".'}`
116}
117
118/** De quantos em quantos passos (ferramentas da conversa principal) uma nota entregue e sem resposta volta. */
119export const INTERVALO_LEMBRETE = 8
120
121/**
122 * As notas entregues que seguem sem resposta há pelo menos `intervalo` passos desde a última entrega. Entregar não é
123 * o mesmo que ser lido: na sessão paralela de 15/10, 6 notas chegaram e nenhuma foi triada.
124 */
125export function paraLembrar(notas: Nota[], ultimaEntrega: Record<string, number>, passo: number, intervalo = INTERVALO_LEMBRETE): Nota[] {
126  return semResposta(notas).filter(n => ultimaEntrega[n.id] !== undefined && passo - ultimaEntrega[n.id]! >= intervalo)
127}
128
129/** O lembrete: o texto inteiro de cada nota de novo, com a idade, e o pedido de triagem. */
130export function lembrete(notas: Nota[], agora: number): string {
131  if (!notas.length) return ''
132  const corpo = notas.map(n => `${n.id} (${n.tipo}${n.ref ? `, sobre ${n.ref}` : ''}, ${idade(n.em, agora)}): ${n.texto}`).join(' | ')
133  return `[caderno] Lembrete: ${notas.length === 1 ? '1 nota do operador segue' : `${notas.length} notas do operador seguem`} sem resposta: ${corpo} — triar cada uma com acao "triar" (aceita, recusada, muda-plano ou registrada, com o motivo); o operador espera a resposta no painel.`
134}
135
136/** Sufixo da linha de cada pedido: quantas notas esperam resposta. */
137export function pendenciaDoCaderno(notas: Nota[]): string {
138  const n = semResposta(notas).length
139  return n ? ` · caderno: ${n} sem resposta` : ''
140}
141
142export const GLIFO_DECISAO: Record<Decisao | 'aguardando', { glifo: string; cor: string; rotulo: string }> = {
143  aguardando: { glifo: '◌', cor: 'warning', rotulo: 'aguardando' },
144  aceita: { glifo: '✔', cor: 'success', rotulo: 'aceita' },
145  recusada: { glifo: '✗', cor: 'error', rotulo: 'recusada' },
146  'muda-plano': { glifo: '↻', cor: 'claude', rotulo: 'mudou o plano' },
147  registrada: { glifo: '·', cor: 'subtle', rotulo: 'registrada' },
148}
149
hooks/roadmap.ts 154 lines
1// O roadmap: o que está planejado além do objetivo em curso. O objetivo é o ciclo de agora e morre no encerrar;
2// o roadmap guarda o plano aprovado entre um objetivo e outro, para que nada que o operador aprovou fique só numa
3// conversa (o plano do herdr de 15/10 ficou numa linha de melhoria e sumiu do ciclo). Horizonte pelo compromisso:
4// agora (entra no próximo objetivo), próximo (o que vem depois dele), depois (quer chegar lá, sem data).
5// Frente agrupa o que é de um mesmo assunto (herdr, painel, prova).
6//
7// Contrato (versão 1): ~/.claude/roadmap/<raiz do projeto, encodeURIComponent>.jsonl, uma linha JSON por evento,
8// só acréscimo, como o caderno. Eventos: `item` (entra), `mover` (muda de horizonte), `promover` (vira a pendência
9// #n do objetivo), `descartar` (sai, com o motivo), `aprovar` (0.21.0). O número R1, R2... é a ordem em que o item
10// entrou no arquivo, estável porque nada é reescrito. Quem lê ignora o que não conhece.
11//
12// Aprovação (0.21.0, R18): o item que o Claude escreve é PROPOSTA até o operador aprovar (botão Aprovar ou Promover no
13// painel; ou o Claude registra a aprovação dita em mensagem, com a frase dele). Em 16/10 o Claude escreveu e promoveu
14// sozinho as abas do painel (R4), e o operador perdeu a visão centralizada: "decisão sua tomada sozinha". Item escrito
15// pelo operador (nota do caderno) ou vindo do objetivo (adiado, devolvido no encerrar) já nasce aprovado.
16
17import type { EventoRoadmap, Horizonte, ItemRoadmap, Owner } from '../types'
18
19export const VERSAO_ROADMAP = 1
20
21export const HORIZONTES: { h: Horizonte; rotulo: string }[] = [
22  { h: 'agora', rotulo: 'agora' },
23  { h: 'proximo', rotulo: 'próximo' },
24  { h: 'depois', rotulo: 'depois' },
25]
26const NOMES = HORIZONTES.map(x => x.h)
27export const ehHorizonte = (h: unknown): h is Horizonte => NOMES.includes(h as Horizonte)
28
29export const linha = (ev: EventoRoadmap) => `${JSON.stringify(ev)}\n`
30
31export function novoItem(texto: string, horizonte: Horizonte, em: string, sufixo: string, autor: Owner, frente?: string, de?: string): EventoRoadmap | null {
32  const t = texto.replace(/\s+/g, ' ').trim()
33  if (!t) return null
34  const f = frente?.trim()
35  return {
36    v: VERSAO_ROADMAP,
37    ev: 'item',
38    id: `r-${em.replace(/\D/g, '').slice(0, 14)}-${sufixo}`,
39    em,
40    autor,
41    texto: t,
42    horizonte,
43    ...(f ? { frente: f } : {}),
44    ...(de ? { de } : {}),
45  }
46}
47
48/** Os itens do arquivo na ordem de entrada, com o estado depois de todos os eventos. Tolerante a lixo. */
49export function lerRoadmap(texto: string): ItemRoadmap[] {
50  const itens = new Map<string, ItemRoadmap>()
51  for (const bruta of texto.split('\n')) {
52    if (!bruta.trim()) continue
53    let o: Record<string, unknown>
54    try {
55      o = JSON.parse(bruta)
56    } catch {
57      continue
58    }
59    if (!o || typeof o !== 'object' || typeof o.v !== 'number' || o.v < 1 || typeof o.id !== 'string' || !o.id) continue
60    if (o.ev === 'item') {
61      if (typeof o.texto !== 'string' || !o.texto.trim() || itens.has(o.id)) continue
62      itens.set(o.id, {
63        n: itens.size + 1,
64        id: o.id,
65        texto: o.texto,
66        // Horizonte desconhecido (de uma versão nova) vale depois: fica no plano sem cobrar agora.
67        horizonte: ehHorizonte(o.horizonte) ? o.horizonte : 'depois',
68        ...(typeof o.frente === 'string' && o.frente ? { frente: o.frente } : {}),
69        ...(typeof o.de === 'string' && o.de ? { de: o.de } : {}),
70        estado: 'aberto',
71        autor: o.autor === 'operador' ? 'operador' : 'claude',
72        aprovado: o.autor === 'operador' || (typeof o.de === 'string' && o.de.startsWith('#')),
73      })
74      continue
75    }
76    const it = itens.get(o.id)
77    if (!it || it.estado !== 'aberto') continue
78    if (o.ev === 'aprovar') it.aprovado = true
79    else if (o.ev === 'mover' && ehHorizonte(o.horizonte)) it.horizonte = o.horizonte
80    else if (o.ev === 'promover' && Number.isInteger(o.pendencia)) {
81      it.estado = 'promovido'
82      it.pendencia = o.pendencia as number
83    } else if (o.ev === 'descartar' && typeof o.motivo === 'string' && o.motivo.trim()) {
84      it.estado = 'descartado'
85      it.motivo = o.motivo
86    }
87  }
88  return [...itens.values()]
89}
90
91export const abertos = (itens: ItemRoadmap[]) => itens.filter(i => i.estado === 'aberto')
92/** As propostas do Claude que esperam o operador. */
93export const propostas = (itens: ItemRoadmap[]) => abertos(itens).filter(i => !i.aprovado)
94export const doHorizonte = (itens: ItemRoadmap[], h: Horizonte) => abertos(itens).filter(i => i.horizonte === h)
95const rotulo = (h: Horizonte) => HORIZONTES.find(x => x.h === h)!.rotulo
96const comFrente = (i: ItemRoadmap) => `${i.frente ? `[${i.frente}] ` : ''}${i.texto}`
97
98/** Resposta da ferramenta: os abertos por horizonte, e o que já saiu, numa linha. */
99export function resumoRoadmap(itens: ItemRoadmap[]): string {
100  const abertosHoje = abertos(itens)
101  if (!itens.length) return 'Roadmap vazio.'
102  const blocos = HORIZONTES.map(({ h }) => {
103    const doH = abertosHoje.filter(i => i.horizonte === h)
104    return doH.length ? [`${rotulo(h)}:`, ...doH.map(i => `  R${i.n} ${i.aprovado ? '' : '(proposta) '}${comFrente(i)}${i.de ? ` (de ${i.de})` : ''}`)].join('\n') : ''
105  }).filter(Boolean)
106  const promovidos = itens.filter(i => i.estado === 'promovido')
107  const descartados = itens.filter(i => i.estado === 'descartado')
108  const saidos = [
109    promovidos.length ? `promovidos: ${promovidos.map(i => `R${i.n}→#${i.pendencia}`).join(', ')}` : '',
110    descartados.length ? `descartados: ${descartados.map(i => `R${i.n}`).join(', ')}` : '',
111  ].filter(Boolean)
112  return [`Roadmap · ${abertosHoje.length} abertos`, ...blocos, ...(saidos.length ? [saidos.join(' · ')] : [])].join('\n')
113}
114
115/**
116 * Sufixo da linha de cada pedido, só quando não há o que fazer no objetivo (nenhum ativo ou tudo feito): o que o
117 * roadmap tem em agora, para o próximo objetivo sair do plano e não do zero.
118 */
119export function lembreteRoadmap(itens: ItemRoadmap[], semPendencia: boolean): string {
120  if (!semPendencia) return ''
121  // Só o aprovado é "já aprovado"; a proposta em agora espera o operador (R18).
122  const agora = doHorizonte(itens, 'agora').filter(i => i.aprovado)
123  const esperam = doHorizonte(itens, 'agora').filter(i => !i.aprovado)
124  const partes = [
125    agora.length ? ` · roadmap em agora: ${agora.map(i => `R${i.n}`).join(', ')} (já aprovado: promova com a ferramenta roadmap, acao "promover", e siga neste turno, sem pedir licença)` : '',
126    esperam.length ? ` · propostas em agora esperando o operador: ${esperam.map(i => `R${i.n}`).join(', ')} (não promova: pergunte pela AskUserQuestion)` : '',
127  ]
128  return partes.join('')
129}
130
131/**
132 * A faixa acima do prompt quando não há o que fazer no objetivo (nenhum ativo ou tudo feito): as contagens do plano
133 * e a próxima dele, a primeira aberta em agora (senão em próximo, senão em depois). Sem isto, a prioridade sumia da
134 * faixa ao encerrar o objetivo, e uma sessão via o que fazer e a outra não (16/10).
135 */
136export function faixaRoadmap(itens: ItemRoadmap[], largura: number, cabeca = '◎ sem objetivo ativo'): string {
137  const ab = abertos(itens)
138  if (!ab.length) return `${cabeca} · roadmap vazio: /objetivo texto :: passo que conclui`
139  const contagem = HORIZONTES.map(x => `${x.rotulo} ${doHorizonte(itens, x.h).length}`).join(' · ')
140  const prox = NOMES.map(h => doHorizonte(itens, h)[0]).find(Boolean)!
141  const base = `${cabeca} · roadmap: ${contagem} · próxima: R${prox.n} `
142  const texto = comFrente(prox)
143  const cabe = Math.max(1, largura - base.length)
144  return base + (texto.length <= cabe ? texto : `${texto.slice(0, cabe - 1).trimEnd()}…`)
145}
146
147/** De onde vem o horizonte num destino de triagem: `roadmap`, `roadmap:agora`, `roadmap proximo`... */
148export function destinoRoadmap(destino: string | undefined): Horizonte | null {
149  const m = /^\s*roadmap\b[\s:/-]*(\S+)?/i.exec(destino ?? '')
150  if (!m) return null
151  const h = (m[1] ?? '').toLowerCase().replace('próximo', 'proximo')
152  return ehHorizonte(h) ? h : 'depois'
153}
154
hooks/painel.ts 158 lines
1// Peças puras do painel: medidores, texto que cabe na largura, o estado do agente e os indicadores de outros mods.
2import type { AoVivoProva, Atividade, Estado, EstadoHerdr, Indicador, VizinhoHerdr } from '../types'
3
4/** Corta o texto em `max` colunas com reticências; devolve o pedaço que coube e o resto (para o hover). */
5export function encurtar(texto: string, max: number): { curto: string; resto: string } {
6  const t = texto.replace(/\s+/g, ' ').trim()
7  const m = Math.max(2, Math.floor(max))
8  if (t.length <= m) return { curto: t, resto: '' }
9  return { curto: `${t.slice(0, m - 1)}…`, resto: t.slice(m - 1) }
10}
11
12// O vocabulário é o do herdr (idle, working, blocked, done): o mesmo estado vale para esta sessão e,
13// quando o painel mostrar outros workspaces, para os agentes deles.
14export const ESTADOS: Record<Estado, { rotulo: string; cor: string; glifo: string }> = {
15  ocioso: { rotulo: 'ocioso', cor: 'subtle', glifo: '○' },
16  trabalhando: { rotulo: 'trabalhando', cor: 'claude', glifo: '●' },
17  bloqueado: { rotulo: 'esperando você', cor: 'warning', glifo: '◆' },
18  concluido: { rotulo: 'concluído', cor: 'success', glifo: '✔' },
19}
20
21export function textoAtividade(a: Atividade | null): string {
22  if (!a) return ESTADOS.ocioso.rotulo
23  const base = ESTADOS[a.estado].rotulo
24  return a.estado === 'trabalhando' && a.ferramenta ? `${base} · ${nomeFerramenta(a.ferramenta)}` : base
25}
26
27/** Centraliza o texto em `largura` colunas, cortando com reticências quando não cabe. */
28export function centralizar(texto: string, largura: number): string {
29  const t = texto.length > largura ? `${texto.slice(0, largura - 1)}…` : texto
30  const esq = Math.floor((largura - t.length) / 2)
31  return ' '.repeat(esq) + t + ' '.repeat(largura - t.length - esq)
32}
33
34/** `claude-opus-5-5` vira `Opus 5.5`; `claude-haiku-4-5-20251001`, `Haiku 4.5`; alias e outros nomes ficam como vieram. */
35export function nomeModelo(id: string): string {
36  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:\[.*\])?$/.exec(id)
37  if (!m) return id
38  return `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
39}
40
41/** `mcp__servidor__ferramenta` vira `servidor·ferramenta`; o resto fica como veio. */
42export function nomeFerramenta(t: string): string {
43  const m = /^mcp__(.+?)__(.+)$/.exec(t)
44  return m ? `${m[1]}·${m[2]}` : t
45}
46
47// Indicadores: o contrato entre mods. Cada mod que tem um número a mostrar escreve
48// ~/.claude/indicadores/<fonte>.json = { versao: 1, fonte, itens: Indicador[] }, e o painel os lê.
49// Arquivo e não $.state: vale entre sessões e workspaces, e nenhum mod depende de outro estar instalado.
50export const VERSAO_INDICADORES = 1
51const ESTADOS_INDICADOR = ['ok', 'atencao', 'alerta'] as const
52
53/** Os indicadores válidos de um arquivo; o que não segue o contrato fica de fora, sem derrubar o resto. */
54export function validarIndicadores(json: unknown): Indicador[] {
55  if (!json || typeof json !== 'object') return []
56  const j = json as { versao?: unknown; fonte?: unknown; itens?: unknown }
57  if (j.versao !== VERSAO_INDICADORES || typeof j.fonte !== 'string' || !Array.isArray(j.itens)) return []
58  const fonte = j.fonte
59  const r: Indicador[] = []
60  for (const x of j.itens as unknown[]) {
61    if (!x || typeof x !== 'object') continue
62    const i = x as Record<string, unknown>
63    if (typeof i.id !== 'string' || !i.id || typeof i.rotulo !== 'string' || !i.rotulo) continue
64    if (typeof i.valor !== 'number' || !Number.isFinite(i.valor)) continue
65    r.push({
66      fonte,
67      id: i.id,
68      rotulo: i.rotulo,
69      valor: i.valor,
70      ...(typeof i.meta === 'number' && Number.isFinite(i.meta) && i.meta > 0 ? { meta: i.meta } : {}),
71      ...(typeof i.unidade === 'string' ? { unidade: i.unidade } : {}),
72      ...(ESTADOS_INDICADOR.includes(i.estado as never) ? { estado: i.estado as Indicador['estado'] } : {}),
73      ...(typeof i.detalhe === 'string' ? { detalhe: i.detalhe } : {}),
74      ...(typeof i.texto === 'string' && i.texto.trim() ? { texto: i.texto.trim() } : {}),
75    })
76  }
77  return r
78}
79
80export const COR_INDICADOR = { ok: 'success', atencao: 'warning', alerta: 'error' } as const
81
82/** O centro do gauge de um indicador: o texto que a fonte mandou; sem ele, valor/meta, ou o valor. */
83export function centroIndicador(i: Indicador): string {
84  return i.texto ?? (i.meta !== undefined ? `${i.valor}/${i.meta}` : `${i.valor}`)
85}
86
87// Vizinhos: os outros workspaces do herdr, escritos pelo mod herdr-vizinhos em
88// ~/.claude/herdr/vizinhos/<HERDR_PANE_ID, encodeURIComponent>.json = { versao: 1, fonte: 'herdr', em, workspace, vizinhos }.
89// Sem o mod ou fora do herdr o arquivo não existe e o painel não muda: o contexto-bar não depende dele.
90export const VERSAO_VIZINHOS = 1
91const HERDR: Record<EstadoHerdr, Estado> = { idle: 'ocioso', working: 'trabalhando', blocked: 'bloqueado', done: 'concluido', unknown: 'ocioso' }
92const ehEstadoHerdr = (x: unknown): x is EstadoHerdr => typeof x === 'string' && x in HERDR
93
94/** O estado do herdr no vocabulário do painel (o desconhecido conta como ocioso). */
95export const estadoDoHerdr = (e: EstadoHerdr): Estado => HERDR[e]
96
97/** Os vizinhos válidos de um arquivo; o que não segue o contrato fica de fora. */
98export function validarVizinhos(json: unknown): VizinhoHerdr[] {
99  const j = json as { versao?: unknown; fonte?: unknown; vizinhos?: unknown } | null
100  if (!j || typeof j !== 'object' || j.versao !== VERSAO_VIZINHOS || j.fonte !== 'herdr' || !Array.isArray(j.vizinhos)) return []
101  const r: VizinhoHerdr[] = []
102  for (const v of j.vizinhos as Record<string, unknown>[]) {
103    if (!v || typeof v !== 'object' || typeof v.workspace !== 'string' || !v.workspace) continue
104    const agentes = (Array.isArray(v.agentes) ? (v.agentes as Record<string, unknown>[]) : [])
105      .filter(a => a && typeof a === 'object' && typeof a.pane === 'string' && a.pane)
106      .map(a => ({ pane: a.pane as string, estado: ehEstadoHerdr(a.estado) ? a.estado : ('unknown' as const), titulo: typeof a.titulo === 'string' ? a.titulo : '' }))
107    r.push({
108      workspace: v.workspace,
109      rotulo: typeof v.rotulo === 'string' && v.rotulo.trim() ? v.rotulo.trim() : v.workspace,
110      estado: ehEstadoHerdr(v.estado) ? v.estado : 'unknown',
111      agentes,
112    })
113  }
114  return r
115}
116
117/** A linha da faixa: só os vizinhos que esperam o operador ou terminaram; nada quando todos seguem. */
118export function faixaVizinhos(vs: VizinhoHerdr[]): string {
119  return vs
120    .filter(v => v.estado === 'blocked' || v.estado === 'done')
121    .map(v => (v.estado === 'blocked' ? `${ESTADOS.bloqueado.glifo} ${v.rotulo} esperando você` : `${ESTADOS.concluido.glifo} ${v.rotulo} terminou`))
122    .join(' · ')
123}
124
125// Ao vivo: a rodada do executar, escrita pelo prova-de-teste em ~/.claude/prova-de-teste/ao-vivo/<id da sessão>.json
126// = { versao: 1, fonte: 'prova-de-teste', tarefa, fase, total, feitos, mortos, sobreviventes, naoAplicados, inicio, agora, copias?, estado? }.
127const FASES = ['base', 'mutantes', 'fim'] as const
128const numero = (x: unknown) => typeof x === 'number' && Number.isFinite(x) && x >= 0
129
130/** A rodada válida do arquivo ao vivo; o que não segue o contrato é nada. */
131export function validarAoVivo(json: unknown): AoVivoProva | null {
132  const j = json as Record<string, unknown> | null
133  if (!j || typeof j !== 'object' || j.versao !== 1 || j.fonte !== 'prova-de-teste') return null
134  if (typeof j.tarefa !== 'string' || !FASES.includes(j.fase as never)) return null
135  const campos = ['total', 'feitos', 'mortos', 'sobreviventes', 'naoAplicados', 'inicio', 'agora'] as const
136  if (!campos.every(k => numero(j[k]))) return null
137  return {
138    tarefa: j.tarefa,
139    fase: j.fase as AoVivoProva['fase'],
140    ...(Object.fromEntries(campos.map(k => [k, j[k] as number])) as Pick<AoVivoProva, (typeof campos)[number]>),
141    ...(numero(j.copias) ? { copias: j.copias as number } : {}),
142    ...(typeof j.estado === 'string' && j.estado ? { estado: j.estado } : {}),
143  }
144}
145
146/** A linha da rodada: a fase, a barra dos mutantes feitos, mortos e sobreviventes, o tempo e o estado no fim. */
147export function linhaAoVivo(v: AoVivoProva, largura: number): string {
148  const s = Math.max(0, Math.round((v.agora - v.inicio) / 1000))
149  const tempo = `${s} s`
150  if (v.fase === 'base') return `linha de base · ${v.total} mutantes na fila · ${tempo}`
151  const contas = `${v.mortos} mortos · ${v.sobreviventes} sobreviventes`
152  if (v.fase === 'fim') return `${v.estado ?? 'fim'} · ${contas}${v.naoAplicados ? ` · ${v.naoAplicados} não aplicados` : ''} · ${tempo}`
153  const texto = ` ${v.feitos}/${v.total} · ${contas}${v.copias && v.copias > 1 ? ` · ${v.copias} cópias` : ''} · ${tempo}`
154  const barra = Math.max(4, Math.min(20, largura - texto.length - 2))
155  const cheio = v.total ? Math.round((v.feitos / v.total) * barra) : 0
156  return `[${'■'.repeat(cheio)}${'·'.repeat(barra - cheio)}]${texto}`
157}
158
hooks/gauge.ts 107 lines
1// Gauges em arco para o painel: 11 colunas por 3 linhas, o número no meio da linha de baixo.
2// Dois desenhos. Braille (2×4 pontos por célula, ~30 posições no arco) onde o terminal desenha braille por código
3// (Kitty, Ghostty, foot); caixa (11 segmentos ╭─╮ ╱╲ │) em qualquer outro, inclusive Alacritty e tmux antigo.
4// Cada célula tem uma cor só: a do trecho cheio ou a da trilha. Pesquisa de 15/10/2026 (publico/pesquisas).
5
6export const LARGURA_GAUGE = 11
7export const LINHAS_GAUGE = 3
8export type EstiloGauge = 'braille' | 'caixa'
9/** Uma célula do arco: o caractere e se está no trecho cheio (`true`), na trilha (`false`) ou vazia (`null`). */
10export type Celula = { ch: string; cheio: boolean | null }
11
12const MEIO = { de: 2, ate: 8 } // colunas livres na linha de baixo, onde vai o número
13
14/** Braille pelo terminal: Kitty, Ghostty e foot desenham por código; o resto cai na caixa. Variável força. */
15export function estiloDoTerminal(env: { forcado?: string; kitty?: string; term?: string; termProgram?: string }): EstiloGauge {
16  if (env.forcado === 'braille' || env.forcado === 'caixa') return env.forcado
17  if (env.kitty) return 'braille'
18  if (/kitty|ghostty|foot/i.test(env.term ?? '') || /ghostty|wezterm/i.test(env.termProgram ?? '')) return 'braille'
19  return 'caixa'
20}
21
22const BIT = [
23  [0x01, 0x08],
24  [0x02, 0x10],
25  [0x04, 0x20],
26  [0x40, 0x80],
27]
28
29/** O arco braille: semicírculo de raio ~10 pontos com centro na base, 2 pontos de espessura. */
30function arcoBraille(frac: number): Celula[][] {
31  const W = LARGURA_GAUGE * 2
32  const H = LINHAS_GAUGE * 4
33  const cx = (W - 1) / 2
34  const cy = H - 0.5
35  const bits: number[][] = Array.from({ length: LINHAS_GAUGE }, () => Array(LARGURA_GAUGE).fill(0))
36  const somaT: number[][] = Array.from({ length: LINHAS_GAUGE }, () => Array(LARGURA_GAUGE).fill(0))
37  const pontos: number[][] = Array.from({ length: LINHAS_GAUGE }, () => Array(LARGURA_GAUGE).fill(0))
38  for (let y = 0; y < H; y++) {
39    for (let x = 0; x < W; x++) {
40      const dx = x - cx
41      const dy = cy - y
42      const r = Math.hypot(dx, dy)
43      if (r < 8.6 || r > 10.6) continue
44      const t = 1 - Math.atan2(dy, dx) / Math.PI // 0 na esquerda, 1 na direita, passando pelo topo
45      const lin = y >> 2
46      const col = x >> 1
47      bits[lin]![col]! |= BIT[y % 4]![x % 2]!
48      somaT[lin]![col]! += t
49      pontos[lin]![col]!++
50    }
51  }
52  return bits.map((linha, l) =>
53    linha.map((b, c) => (b ? { ch: String.fromCharCode(0x2800 + b), cheio: frac > 0 && somaT[l]![c]! / pontos[l]![c]! <= frac } : { ch: ' ', cheio: null })),
54  )
55}
56
57// A caixa: 11 segmentos na ordem do arco, da esquerda para a direita.
58const SEGMENTOS: [number, number, string][] = [
59  [2, 0, '│'],
60  [1, 1, '╱'],
61  [0, 2, '╭'],
62  [0, 3, '─'],
63  [0, 4, '─'],
64  [0, 5, '─'],
65  [0, 6, '─'],
66  [0, 7, '─'],
67  [0, 8, '╮'],
68  [1, 9, '╲'],
69  [2, 10, '│'],
70]
71
72function arcoCaixa(frac: number): Celula[][] {
73  const grade: Celula[][] = Array.from({ length: LINHAS_GAUGE }, () => Array.from({ length: LARGURA_GAUGE }, () => ({ ch: ' ', cheio: null })))
74  const acesos = Math.round(frac * SEGMENTOS.length)
75  SEGMENTOS.forEach(([l, c, ch], i) => {
76    grade[l]![c] = { ch, cheio: i < acesos }
77  })
78  return grade
79}
80
81/**
82 * O gauge inteiro: o arco com o número centrado nas colunas livres da linha de baixo. `frac` vai a 0..1
83 * (valores fora, NaN e infinito ficam na borda mais próxima, ou vazios).
84 */
85export function gauge(frac: number, centro: string, estilo: EstiloGauge): Celula[][] {
86  const f = Number.isFinite(frac) ? Math.min(1, Math.max(0, frac)) : 0
87  const grade = estilo === 'braille' ? arcoBraille(f) : arcoCaixa(f)
88  const espaco = MEIO.ate - MEIO.de + 1
89  const txt = centro.length > espaco ? `${centro.slice(0, espaco - 1)}…` : centro
90  const inicio = MEIO.de + Math.floor((espaco - txt.length) / 2)
91  ;[...txt].forEach((ch, i) => {
92    grade[LINHAS_GAUGE - 1]![inicio + i] = { ch, cheio: null }
93  })
94  return grade
95}
96
97/** Junta células vizinhas do mesmo estado em pedaços, para desenhar cada linha com poucas cores. */
98export function pedacos(linha: Celula[]): { texto: string; cheio: boolean | null }[] {
99  const r: { texto: string; cheio: boolean | null }[] = []
100  for (const c of linha) {
101    const ult = r[r.length - 1]
102    if (ult && ult.cheio === c.cheio) ult.texto += c.ch
103    else r.push({ texto: c.ch, cheio: c.cheio })
104  }
105  return r
106}
107
types/index.d.ts 162 lines
1export type GroupKey = 'sistema' | 'claudemd' | 'mcp' | 'skills' | 'hooks' | 'conversa' | 'livre' | 'reserva'
2
3export type Segment = { key: GroupKey; tokens: number }
4
5export type Snapshot = {
6  /** Uso sobre a janela de compactação, 0-100. */
7  percent: number
8  /** Soma de todos os segmentos (inclui livre e reserva). */
9  total: number
10  segments: Segment[]
11}
12
13export type Owner = 'claude' | 'operador'
14export type Size = 'P' | 'M' | 'G'
15/** Prioridade: bloqueante trava outra pendência ou a entrega; requisito é preciso para a entrega; melhoria pode esperar. */
16export type Nivel = 'bloqueante' | 'requisito' | 'melhoria'
17
18/** 0.18.0: como uma frente roda (ACIONAMENTO.md do herdr-vizinhos): em paralelo ou em fila, por qual meio, em qual harness. */
19export type Disparo = { modo: 'paralelo' | 'fila'; meio: 'sessao' | 'subagente' | 'workflow' | 'herdr'; harness?: string }
20
21export type GoalItem = { id: number; texto: string; dono: Owner; tamanho: Size; feito: boolean; /** Ausente nos objetivos gravados antes da 0.5.0: vale requisito. */ nivel?: Nivel; disparo?: Disparo }
22
23export type Goal = {
24  titulo: string
25  /** O passo que encerra o objetivo. */
26  conclusao: string
27  origem: Owner
28  itens: GoalItem[]
29  /** Contexto em uso na última pendência do Claude concluída (ou na definição). */
30  marco: number
31  /** Tokens de janela por unidade de peso, das últimas pendências concluídas. */
32  consumo: number[]
33  atualizadoTurno: number
34  /** Último aviso de janela dado ao modelo, para não repetir. */
35  aviso: 'apertado' | 'nao-cabe' | null
36}
37
38/** Somas do cache de prompt da sessão: tokens lidos do cache, tokens de entrada no total e quantas respostas contaram. */
39export type Cache = {
40  lidos: number
41  total: number
42  n: number
43  /** Id da última resposta somada do transcript: o transcript grava uma linha por bloco, todas com o mesmo uso. */
44  ultimoId?: string
45}
46
47/** Um comando no segundo plano: id da tarefa, o que é, onde escreve, quando começou e a última mudança vista na saída. */
48export type Tarefa = { id: string; descricao: string; saida?: string; inicio: number; mtime?: number }
49
50export type SegundoPlano = {
51  tarefas: Tarefa[]
52  /** Última vez que o agente agendou a volta (ScheduleWakeup, Monitor, CronCreate). */
53  voltaEm: number | null
54  /** Último fim de turno da conversa principal. */
55  fimTurno: number | null
56  /** O Stop disse que há crons da sessão para acordá-la. */
57  cronsAtivos: boolean
58}
59
60/** Estado do agente no vocabulário do herdr: idle, working, blocked, done. */
61export type Estado = 'ocioso' | 'trabalhando' | 'bloqueado' | 'concluido'
62
63/** 0.16.0: um workspace vizinho, como o mod herdr-vizinhos o escreve (contrato em painel.ts). */
64export type EstadoHerdr = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'
65/** 0.19.0: a rodada do prova-de-teste como o executar a escreve enquanto roda (contrato em painel.ts). */
66export type AoVivoProva = { tarefa: string; fase: 'base' | 'mutantes' | 'fim'; total: number; feitos: number; mortos: number; sobreviventes: number; naoAplicados: number; inicio: number; agora: number; copias?: number; estado?: string }
67export type VizinhoHerdr = { workspace: string; rotulo: string; estado: EstadoHerdr; agentes: { pane: string; estado: EstadoHerdr; titulo: string }[] }
68
69export type Atividade = { estado: Estado; /** Ferramenta em curso, quando trabalhando. */ ferramenta?: string; desde: number }
70
71/** Um número que outro mod publica em ~/.claude/indicadores/<fonte>.json para o painel mostrar. */
72export type Indicador = {
73  fonte: string
74  id: string
75  rotulo: string
76  valor: number
77  /** Com meta, o painel desenha um medidor até ela. */
78  meta?: number
79  unidade?: string
80  estado?: 'ok' | 'atencao' | 'alerta'
81  detalhe?: string
82  /** O que vai no centro do gauge ("83%", "4,8 min"); ausente, valor/meta ou o valor. */
83  texto?: string
84}
85
86/** Caderno do operador (contrato em hooks/caderno.ts): nota sem compromisso, agora (deste ciclo), depois (roadmap), referência. */
87export type TipoNota = 'nota' | 'agora' | 'depois' | 'referencia'
88export type Decisao = 'aceita' | 'recusada' | 'muda-plano' | 'registrada'
89export type EventoNota = { v: number; ev: 'nota'; id: string; em: string; autor: 'operador'; tipo: TipoNota; texto: string; /** `#12` ou a próxima pendência quando a nota foi escrita. */ ref?: string }
90export type EventoTriagem = { v: number; ev: 'triagem'; id: string; em: string; autor: 'claude'; decisao: Decisao; motivo: string; /** Para onde foi: `#12`, `roadmap`... */ destino?: string; /** O melhor argumento contra a própria decisão. */ contraponto?: string }
91export type Nota = EventoNota & { triagem?: EventoTriagem }
92
93/** Roadmap (contrato em hooks/roadmap.ts): o plano além do objetivo em curso, por horizonte. */
94export type Horizonte = 'agora' | 'proximo' | 'depois'
95export type EventoRoadmap =
96  | { v: number; ev: 'item'; id: string; em: string; autor: Owner; texto: string; horizonte: Horizonte; frente?: string; /** De onde veio: `#7` (pendência adiada), `n-...` (nota do caderno). */ de?: string }
97  | { v: number; ev: 'mover'; id: string; em: string; horizonte: Horizonte }
98  | { v: number; ev: 'promover'; id: string; em: string; pendencia: number; /** Quem promoveu (0.21.0); o operador promover aprova. */ por?: Owner }
99  /** 0.21.0 (R18): o operador aprova a proposta (botão), ou o Claude registra a aprovação dita em mensagem, com a frase. */
100  | { v: number; ev: 'aprovar'; id: string; em: string; por: Owner; citacao?: string }
101  | { v: number; ev: 'descartar'; id: string; em: string; motivo: string }
102/** Um item com o estado depois de todos os eventos; `n` é a ordem de entrada no arquivo (R1, R2...). */
103export type ItemRoadmap = {
104  n: number
105  id: string
106  texto: string
107  horizonte: Horizonte
108  frente?: string
109  de?: string
110  estado: 'aberto' | 'promovido' | 'descartado'
111  pendencia?: number
112  motivo?: string
113  /** 0.21.0 (R18): quem escreveu o item. */
114  autor: Owner
115  /** Escrito pelo operador, vindo do objetivo (`de` #n) ou aprovado depois; o resto é proposta do Claude. */
116  aprovado: boolean
117}
118
119/** Quem executa: o modelo e o effort da conversa principal e os subagentes vivos. */
120export type Subagente = { id: string; tipo: string; descricao: string; modelo: string; effort?: string; desde: number }
121export type Executores = { principal?: { modelo: string; effort?: string }; subagentes: Subagente[] }
122
123declare module 'claude-code' {
124  interface PluginState {
125    'contexto-bar': {
126      snapshot: Snapshot | null
127      objetivo: Goal | null
128      turno: number
129      /** Somas do cache de prompt da sessão; `parcial` quando o transcript não pôde ser lido e conta só desde o carregamento. */
130      cache: { acc: Cache; parcial: boolean } | null
131      /** Tamanho do último pedido (entrada sem cache + lida + gravada no cache). */
132      pedido: number | null
133      segundoPlano: SegundoPlano | null
134      atividade: Atividade | null
135      /** Pendências feitas abertas no painel (recolhidas por padrão). */
136      verFeitas: boolean
137      indicadores: Indicador[]
138      /** Espelho do painel automático do store, para o botão redesenhar. */
139      painelAuto: boolean | null
140      caderno: Nota[]
141      /** Ids das notas já levadas ao Claude nesta sessão. */
142      entregues: string[]
143      tipoNota: TipoNota
144      /** Conta os envios: muda a chave do campo, que volta vazio depois de cada nota. */
145      envios: number
146      executores: Executores
147      /** O que o operador está escrevendo no caderno, para a prévia (o campo do motor é de uma linha). */
148      rascunho: string
149      /** Passos (ferramentas da conversa principal) desta sessão, e o passo da última entrega de cada nota. */
150      passos: number
151      ultimaEntrega: Record<string, number>
152      roadmap: ItemRoadmap[]
153      /** Próximo e depois abertos no painel (agora aparece sempre). */
154      verRoadmap: boolean
155      /** Os outros workspaces do herdr, do arquivo deste pane; vazio fora do herdr ou sem o mod. */
156      vizinhos: VizinhoHerdr[]
157      /** A rodada do prova-de-teste desta sessão (o arquivo ao vivo do executar); null sem nenhuma. */
158      aoVivo: AoVivoProva | null
159    }
160  }
161}
162