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

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.
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.
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.
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.
Pedi ao agente que construiu e usou os mods uma avaliação do próprio trabalho, sem enfeite. Os números saem do log.
registrar, não na memória do agente. Um estado melhor que o executado é recusado.claude plugin marketplace add aiob3/claude-grounds
claude plugin install contexto-bar@claude-grounds
claude plugin install prova-de-teste@claude-grounds
| Mod | Versão | Papel |
|---|---|---|
contexto-bar | 0.21.0 | faixa de contexto, objetivo por projeto, painel e projeção da janela |
prova-de-teste | 0.16.0 | rodada 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ê digita | O que acontece |
|---|---|
/objetivo | abre o painel |
/objetivo Migrar o login :: testes verdes em produção | define o objetivo e o passo que o encerra |
/objetivo feito 2 3 | conclui as pendências 2 e 3 |
/objetivo bloqueante 4 | sobe a pendência 4 para o topo da fila |
/objetivo meu revisar o contrato | acrescenta uma pendência sua |
/objetivo encerrar | encerra o objetivo do projeto |
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 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.
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.
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.
<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.
<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.
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]
}
}
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.
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:
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.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 | |
|---|---|---|
| Instalar | editar o arquivo e escrever os scripts | claude plugin install |
| O agente pode chamar | nada | objetivo, executar, registrar, mutantes |
| Estado e memória | o script guarda onde conseguir | objetivo por projeto, última execução, log |
| Na tela | texto no terminal | faixa acima do prompt e painel lateral |
| Verificar o comportamento | à mão | claude 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.
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.
| rodada | por mutante | testes por mutante | vereditos | |
|---|---|---|---|---|
| suíte completa em cada mutante | 407 s | 35,9 s | 8881 | 8 mortos, 2 sobreviventes |
| só os testes afetados | 145 s | 10,0 s | 1906 | os mesmos |
| idem, com o teste reforçado | 107 s | 6,4 s | 1018 | 9 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:
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.
prova-de-teste acha o comando de teste por convenção (claude plugin test, npm test, pytest). Para outro executor, passe comando ao executar.incremental: false desliga.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.
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:
| Toulmin | Aqui |
|---|---|
| claim | "a tarefa está feita", declarada no objetivo (contexto-bar) |
| grounds | red first e o relatório com cada mutante (prova-de-teste) |
| rebuttal | mutantes escolhidos às cegas por um modelo que não viu os testes |
| qualifier | o estado: PROVADO, PROVADO-PARCIAL, FRACO ou NÃO VERIFICADO |
$.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.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).
hooks/register.tsx 1594 lines1import { 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 lines1import 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}
377hooks/cache.ts 52 lines1// 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`
52hooks/segundo-plano.ts 81 lines1// 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}
81hooks/caderno.ts 149 lines1// 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}
149hooks/roadmap.ts 154 lines1// 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}
154hooks/painel.ts 158 lines1// 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}
158hooks/gauge.ts 107 lines1// 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}
107types/index.d.ts 162 lines1export 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