Dentro do herdr, mostra no painel do objetivo os outros workspaces e seus agentes, e avisa quando um fica esperando o operador ou termina; grava e confere o…

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.ts 170 lines1// herdr-vizinhos: dentro do herdr (HERDR_ENV), lê o snapshot a cada poucos segundos, escreve os outros workspaces
2// no contrato de vizinhos.ts para o painel do contexto-bar, e avisa quando um agente vizinho fica blocked ou done:
3// um toast para o operador e uma linha ao modelo no próximo resultado de ferramenta ou pedido.
4// Dentro do herdr registra também a ferramenta briefing (briefing.ts): o arquivo de cada frente delegada e a
5// conferência do escopo do que ela tocou. Fora do herdr não registra nada: o claude-grounds funciona igual sem este mod.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer } from 'claude-code'
9
10import type { Briefing } from './briefing'
11import { arquivoBriefing, faltas, foraDoEscopo, GITIGNORE_HERDR, lerBriefing, pastaDosBriefings, promptDoPane, textoBriefing, textoConferencia, tocadosDe } from './briefing'
12import { arquivoVizinhos, contextoAvisos, conteudo, estadosDe, lerSnapshot, mudancas, regraHabilitado, textoAviso } from './vizinhos'
13
14const estados = atom({ plugin: 'herdr-vizinhos', key: 'estados' } as const, null)
15const pendentes = atom({ plugin: 'herdr-vizinhos', key: 'pendentes' } as const, [])
16const vizinhos = atom({ plugin: 'herdr-vizinhos', key: 'vizinhos' } as const, [])
17const regraEnviada = atom({ plugin: 'herdr-vizinhos', key: 'regraEnviada' } as const, false)
18
19/** De quanto em quanto tempo o snapshot é relido (ele responde em poucos milissegundos). */
20export const INTERVALO_MS = 5000
21
22let timer: Timer | undefined
23
24export const TOOL_BRIEFING = 'mcp__herdr-vizinhos__briefing'
25
26const SPEC_BRIEFING = {
27 name: 'briefing',
28 description:
29 'Briefing por frente (herdr-vizinhos/ACIONAMENTO.md, etapa 2). escrever: grava <pasta>/.herdr/frentes/<frente>.md com objetivo, arquivos, ' +
30 'o que conta como feito e o que não tocar, e devolve a instrução curta para o pane (o agente delegado lê o arquivo, não a conversa). ' +
31 'ler: devolve o briefing. conferir: os arquivos tocados na pasta (git diff HEAD e os novos) contra "Arquivos" e "O que não tocar"; ' +
32 'rode antes da prova da frente.',
33 isDeferred: false,
34 inputSchema: {
35 type: 'object',
36 properties: {
37 acao: { type: 'string', enum: ['escrever', 'ler', 'conferir'] },
38 frente: { type: 'string', description: 'Nome da frente (vira o nome do arquivo).' },
39 pasta: { type: 'string', description: 'Pasta da frente, onde o agente delegado trabalha (padrão: a da sessão).' },
40 objetivo: { type: 'string', description: 'Para escrever: o que a frente entrega, em uma ou duas frases.' },
41 arquivos: { type: 'array', items: { type: 'string' }, description: 'Para escrever: arquivos ou pastas (terminadas em /, ou com * e **) que a frente pode mudar; relativos à pasta.' },
42 feito: { type: 'string', description: 'Para escrever: o que conta como feito (o teste que passa, o efeito visto).' },
43 naoTocar: { type: 'array', items: { type: 'string' }, description: 'Para escrever: o que a frente não pode mudar.' },
44 pendencia: { type: 'integer', description: 'Para escrever: o id da pendência do objetivo.' },
45 harness: { type: 'string', description: 'Para escrever: o agente que roda a frente (claude, codex…).' },
46 },
47 required: ['acao', 'frente'],
48 },
49} as const
50
51type ArgsBriefing = Partial<Briefing> & { acao?: string; pasta?: string }
52
53async function briefing($: EngineInterface, a: ArgsBriefing): Promise<{ texto: string; erro: boolean }> {
54 const pasta = a.pasta?.trim() || (await $.session.cwd())
55 if (!a.frente?.trim()) return { texto: 'Erro: falta frente.', erro: true }
56 const caminho = arquivoBriefing(pasta, a.frente)
57 if (a.acao === 'escrever') {
58 const f = faltas(a)
59 if (f.length) return { texto: `Erro: o briefing precisa de ${f.join(', ')}.`, erro: true }
60 const b: Briefing = { frente: a.frente.trim(), objetivo: a.objetivo!, arquivos: a.arquivos ?? [], feito: a.feito!, naoTocar: a.naoTocar ?? [], ...(a.pendencia ? { pendencia: a.pendencia } : {}), ...(a.harness?.trim() ? { harness: a.harness.trim() } : {}) }
61 const ignore = `${pastaDosBriefings(pasta).replace(/\/frentes$/, '')}/.gitignore`
62 if (!(await $.fs.exists(ignore))) await $.fs.write(ignore, GITIGNORE_HERDR)
63 await $.fs.write(caminho, textoBriefing(b, new Date(await $.clock.now()).toISOString()))
64 return { texto: `Briefing gravado em ${caminho}.\nInstrução para o pane: ${promptDoPane(caminho)}`, erro: false }
65 }
66 if (!(await $.fs.exists(caminho))) return { texto: `Erro: não há briefing da frente "${a.frente}" em ${caminho}; escreva-o antes.`, erro: true }
67 const texto = await $.fs.read(caminho)
68 if (a.acao === 'ler') return { texto, erro: false }
69 if (a.acao !== 'conferir') return { texto: 'Erro: acao é escrever, ler ou conferir.', erro: true }
70 const b = lerBriefing(texto)
71 if (!b) return { texto: `Erro: ${caminho} não é um briefing v1 (o cabeçalho mudou?).`, erro: true }
72 const [diff, novos] = await Promise.all([
73 $.process.run(['git', 'diff', '--name-only', '--relative', 'HEAD'], { cwd: pasta }),
74 $.process.run(['git', 'ls-files', '-o', '--exclude-standard'], { cwd: pasta }),
75 ])
76 if (diff.exitCode !== 0 || novos.exitCode !== 0) return { texto: `Erro: o git não leu ${pasta} (${(diff.stderr || novos.stderr).trim()}).`, erro: true }
77 const tocados = tocadosDe(diff.stdout, novos.stdout)
78 return { texto: textoConferencia(b, tocados, foraDoEscopo(tocados, b)), erro: false }
79}
80
81async function atualizar($: EngineInterface): Promise<void> {
82 const [pane, ws, home, bin] = await Promise.all([$.env.get('HERDR_PANE_ID'), $.env.get('HERDR_WORKSPACE_ID'), $.env.get('HOME'), $.env.get('HERDR_BIN_PATH')])
83 if (!pane || !ws || !home) return
84 const r = await $.process.run([bin || 'herdr', 'api', 'snapshot'])
85 if (r.exitCode !== 0) return
86 const vs = lerSnapshot(r.stdout, ws)
87 if (!vs) return
88 const novas = mudancas(await read($, estados), vs).map(textoAviso)
89 await update($, estados, () => estadosDe(vs))
90 if (novas.length) {
91 await update($, pendentes, p => [...p, ...novas])
92 for (const t of novas) $.ui.toast(`herdr: ${t}`, { timeoutMs: 8000 })
93 }
94 if (JSON.stringify(await read($, vizinhos)) === JSON.stringify(vs)) return
95 await update($, vizinhos, () => vs)
96 await $.fs.write(arquivoVizinhos(home, pane), conteudo(new Date(await $.clock.now()).toISOString(), ws, vs))
97}
98
99/** Os avisos ainda não entregues, uma vez só. */
100async function entregar($: EngineInterface): Promise<string | undefined> {
101 const p = await read($, pendentes)
102 if (!p.length) return undefined
103 await update($, pendentes, () => [])
104 return contextoAvisos(p)
105}
106
107const dentroDoHerdr = async ($: EngineInterface) => !!(await $.env.get('HERDR_ENV'))
108
109/** Relê o herdr e devolve o aviso a entregar; uma falha aqui nunca segura a ferramenta nem o pedido. */
110async function lerEEntregar($: EngineInterface): Promise<string | undefined> {
111 if (!(await dentroDoHerdr($))) return undefined
112 await atualizar($)
113 return entregar($)
114}
115
116export const register: Register = on => {
117 on('session.start', async ($, e, next) => {
118 const result = await next(e)
119 if (e.agentId || !(await dentroDoHerdr($).catch(() => false))) return result
120 await $.tool.register(SPEC_BRIEFING).catch(() => undefined)
121 await atualizar($).catch(() => undefined)
122 timer?.cancel()
123 timer = $.clock.every(INTERVALO_MS, () => void atualizar($).catch(() => undefined))
124 return result
125 })
126
127 on('tool.call', { tool: TOOL_BRIEFING as never }, async ($, e) => {
128 const r = await briefing($, e as never).catch(err => ({ texto: `Erro: ${err instanceof Error ? err.message : String(err)}`, erro: true }))
129 return { result: r.texto, ...(r.erro ? { isError: true } : {}) } as never
130 })
131
132 on('tool.call', async ($, e, next) => {
133 const result = await next(e)
134 if (e.agentId) return result
135 const ctx = await lerEEntregar($).catch(() => undefined)
136 return ctx ? { ...result, context: [...(result.context ?? []), ctx] } : result
137 })
138
139 on('prompt.submit', async ($, e, next) => {
140 if (e.command) return next(e)
141 const ctx = await lerEEntregar($).catch(() => undefined)
142 const regra = await (async () => {
143 if (!(await dentroDoHerdr($))) return undefined
144 const inteira = !(await read($, regraEnviada))
145 if (inteira) await update($, regraEnviada, () => true)
146 return regraHabilitado(inteira)
147 })().catch(() => undefined)
148 const extra = [regra, ctx].filter((x): x is string => !!x)
149 return next(extra.length ? { ...e, context: [...(e.context ?? []), ...extra] } : e)
150 })
151
152 // A compactação descarta o texto injetado: a regra inteira volta no próximo pedido.
153 on('session.compact', async ($, e, next) => {
154 const result = await next(e)
155 if (!e.agentId) await update($, regraEnviada, () => false).catch(() => undefined)
156 return result
157 })
158
159 // A sessão que sai leva o arquivo dela: o painel de quem ficou não mostra vizinhos de uma sessão que não existe.
160 on('session.end', async ($, e, next) => {
161 const result = await next(e)
162 timer?.cancel()
163 timer = undefined
164 if (e.agentId) return result
165 const [pane, home] = await Promise.all([$.env.get('HERDR_PANE_ID'), $.env.get('HOME')]).catch(() => [undefined, undefined])
166 if (pane && home) await $.process.run(['rm', '-f', arquivoVizinhos(home, pane)]).catch(() => undefined)
167 return result
168 })
169}
170hooks/briefing.ts 151 lines1// O briefing por frente (ACIONAMENTO.md, etapa 2): o que o agente delegado precisa para trabalhar sem a conversa de
2// quem o criou. Fica num arquivo da pasta da frente, em Markdown sem ferramenta nenhuma, porque quem lê pode ser
3// outro harness (codex, gemini). As seções têm títulos fixos: quem delegou relê o arquivo e confere o escopo do que
4// voltou (os arquivos tocados contra "Arquivos" e "O que não tocar"), que é a base da pendência com agente como dono.
5//
6// Contrato (versão 1): <pasta da frente>/.herdr/frentes/<slug da frente>.md; a 2ª linha é o comentário
7// `<!-- herdr-briefing v1 · ... -->`. A pasta .herdr leva um .gitignore com `*`: o briefing não entra no diff.
8
9export const VERSAO_BRIEFING = 1
10
11export type Briefing = {
12 frente: string
13 objetivo: string
14 /** Os arquivos ou pastas (terminadas em /, ou com * e **) que a frente pode mudar; vazio = qualquer um fora de naoTocar. */
15 arquivos: string[]
16 feito: string
17 naoTocar: string[]
18 pendencia?: number
19 harness?: string
20}
21
22const SECOES = { objetivo: 'Objetivo', arquivos: 'Arquivos', feito: 'O que conta como feito', naoTocar: 'O que não tocar' } as const
23
24/** O nome do arquivo da frente: minúsculo, sem acento, só letras, números e hífen. */
25export function slug(frente: string): string {
26 return frente
27 .normalize('NFD')
28 .replace(/[\u0300-\u036f]/g, '')
29 .toLowerCase()
30 .replace(/[^a-z0-9]+/g, '-')
31 .replace(/^-+|-+$/g, '')
32}
33
34export const pastaDosBriefings = (pasta: string) => `${pasta.replace(/\/+$/, '')}/.herdr/frentes`
35export const arquivoBriefing = (pasta: string, frente: string) => `${pastaDosBriefings(pasta)}/${slug(frente)}.md`
36export const GITIGNORE_HERDR = '*\n'
37
38/** O que falta para o briefing valer: sem objetivo ou sem o que conta como feito, o agente delegado não sabe quando parar. */
39export function faltas(b: Partial<Briefing>): string[] {
40 const f: string[] = []
41 if (!b.frente?.trim() || !slug(b.frente)) f.push('frente')
42 if (!b.objetivo?.trim()) f.push('objetivo')
43 if (!b.feito?.trim()) f.push('feito')
44 return f
45}
46
47const lista = (xs: string[], vazio: string) => (xs.length ? xs.map(x => `- \`${x}\``).join('\n') : vazio)
48
49/** O texto do arquivo. */
50export function textoBriefing(b: Briefing, em: string): string {
51 const meta = [`herdr-briefing v${VERSAO_BRIEFING}`, em, ...(b.pendencia ? [`pendência #${b.pendencia}`] : []), ...(b.harness ? [`harness ${b.harness}`] : [])]
52 return [
53 `# Frente: ${b.frente.trim()}`,
54 `<!-- ${meta.join(' · ')} -->`,
55 '',
56 `## ${SECOES.objetivo}`,
57 '',
58 b.objetivo.trim(),
59 '',
60 `## ${SECOES.arquivos}`,
61 '',
62 lista(b.arquivos, 'Qualquer arquivo da pasta, fora dos de "O que não tocar".'),
63 '',
64 `## ${SECOES.feito}`,
65 '',
66 b.feito.trim(),
67 '',
68 `## ${SECOES.naoTocar}`,
69 '',
70 lista(b.naoTocar, 'Nada além do que está fora de "Arquivos".'),
71 '',
72 '## Ao terminar',
73 '',
74 'Pare com o resultado na árvore de trabalho, sem commit. Quem delegou confere o escopo (os arquivos tocados contra as duas listas acima) e roda a prova.',
75 '',
76 ].join('\n')
77}
78
79/** O briefing de volta, do arquivo; null quando não é um briefing desta versão. */
80export function lerBriefing(texto: string): Briefing | null {
81 const linhas = texto.split('\n')
82 const frente = /^# Frente: (.+)$/.exec(linhas[0] ?? '')?.[1]?.trim()
83 const meta = /^<!-- herdr-briefing v(\d+)(.*)-->$/.exec(linhas[1] ?? '')
84 if (!frente || !meta || Number(meta[1]) !== VERSAO_BRIEFING) return null
85 const secoes = new Map<string, string[]>()
86 let atual: string | undefined
87 for (const l of linhas.slice(2)) {
88 const h = /^## (.+)$/.exec(l)
89 if (h) secoes.set((atual = h[1]!.trim()), [])
90 else if (atual) secoes.get(atual)!.push(l)
91 }
92 const corpo = (s: string) => (secoes.get(s) ?? []).join('\n').trim()
93 const itens = (s: string) => (secoes.get(s) ?? []).flatMap(l => /^- `(.+)`$/.exec(l.trim())?.[1] ?? [])
94 const pendencia = /pendência #(\d+)/.exec(meta[2]!)?.[1]
95 const harness = /harness (\S+)/.exec(meta[2]!)?.[1]
96 return {
97 frente,
98 objetivo: corpo(SECOES.objetivo),
99 arquivos: itens(SECOES.arquivos),
100 feito: corpo(SECOES.feito),
101 naoTocar: itens(SECOES.naoTocar),
102 ...(pendencia ? { pendencia: Number(pendencia) } : {}),
103 ...(harness ? { harness } : {}),
104 }
105}
106
107/** Um padrão da lista contra um caminho: igual, a pasta (terminada em /) e o que há nela, `*` dentro de um nível, `**` em qualquer. */
108export function casa(padrao: string, caminho: string): boolean {
109 const p = padrao.trim().replace(/^\.\//, '')
110 if (!p) return false
111 if (p.endsWith('/')) return caminho.startsWith(p)
112 if (!p.includes('*')) return caminho === p || caminho.startsWith(`${p}/`)
113 const re = p
114 .split('**')
115 .map(parte => parte.split('*').map(x => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*'))
116 .join('.*')
117 return new RegExp(`^${re}$`).test(caminho)
118}
119
120/** Os arquivos tocados que saem do escopo: os proibidos (em naoTocar) e os de fora (não estão em arquivos, quando há lista). */
121export function foraDoEscopo(tocados: string[], b: Pick<Briefing, 'arquivos' | 'naoTocar'>): { proibidos: string[]; fora: string[] } {
122 const proibidos = tocados.filter(t => b.naoTocar.some(p => casa(p, t)))
123 const fora = b.arquivos.length ? tocados.filter(t => !proibidos.includes(t) && !b.arquivos.some(p => casa(p, t))) : []
124 return { proibidos, fora }
125}
126
127/**
128 * Os arquivos tocados, relativos à pasta da frente: os do `git diff --name-only --relative HEAD` (mudados, apagados, no
129 * índice) e os de `git ls-files -o --exclude-standard` (novos). O status sairia relativo à raiz do repositório e
130 * resumiria a pasta nova num nome só. A pasta .herdr fica de fora.
131 */
132export function tocadosDe(diff: string, novos: string): string[] {
133 const todos = `${diff}\n${novos}`.split('\n').map(l => l.trim()).filter(Boolean)
134 return [...new Set(todos)].filter(c => !c.startsWith('.herdr/'))
135}
136
137/** A instrução curta para o pane: o caminho do briefing e o que fazer ao terminar. */
138export const promptDoPane = (caminho: string) => `Leia ${caminho} e trabalhe só nessa frente. Ao terminar, pare com o resultado na árvore, sem commit.`
139
140/** O resultado da conferência, em texto para o modelo. */
141export function textoConferencia(b: Briefing, tocados: string[], f: { proibidos: string[]; fora: string[] }): string {
142 const cabeca = `Frente "${b.frente}": ${tocados.length} arquivo${tocados.length === 1 ? '' : 's'} tocado${tocados.length === 1 ? '' : 's'}`
143 if (!f.proibidos.length && !f.fora.length) return `${cabeca}, todos no escopo. Feito quando: ${b.feito}`
144 return [
145 `${cabeca}; FORA DO ESCOPO:`,
146 ...f.proibidos.map(t => ` ✘ ${t} (em "O que não tocar")`),
147 ...f.fora.map(t => ` ✘ ${t} (fora de "Arquivos")`),
148 'A frente não está feita: desfaça ou justifique cada um antes da prova.',
149 ].join('\n')
150}
151hooks/vizinhos.ts 94 lines1// Os outros workspaces do herdr, vistos de dentro de uma sessão: a Fase 1 da integração (só leitura).
2// O herdr já agrega o estado de cada workspace a partir dos agentes dele (idle, working, blocked, done);
3// aqui só se separa o que é desta sessão, se vê a mudança e se escreve o contrato que o contexto-bar lê.
4//
5// Contrato (versão 1): ~/.claude/herdr/vizinhos/<pane desta sessão, encodeURIComponent>.json =
6// { versao: 1, fonte: 'herdr', em, workspace, vizinhos: Vizinho[] }. Por pane, porque duas sessões na mesma
7// raiz, em workspaces diferentes, têm vizinhos diferentes. Quem lê ignora o que não conhece.
8
9export const VERSAO_VIZINHOS = 1
10
11import type { AgenteVizinho, EstadoHerdr, Vizinho } from '../types'
12
13const ESTADOS: EstadoHerdr[] = ['idle', 'working', 'blocked', 'done', 'unknown']
14const ehEstado = (x: unknown): x is EstadoHerdr => ESTADOS.includes(x as EstadoHerdr)
15// Na falta do agregado do herdr, o que mais pede o operador vence.
16const PESO: Record<EstadoHerdr, number> = { blocked: 4, done: 3, working: 2, idle: 1, unknown: 0 }
17
18/** Os workspaces que não são o desta sessão, na ordem do herdr; null quando a resposta não é um snapshot. */
19export function lerSnapshot(texto: string, meuWorkspace: string): Vizinho[] | null {
20 let s: { workspaces?: unknown; agents?: unknown }
21 try {
22 s = (JSON.parse(texto) as { result?: { snapshot?: typeof s } }).result?.snapshot ?? (null as never)
23 } catch {
24 return null
25 }
26 if (!s || !Array.isArray(s.workspaces)) return null
27 const agentes = Array.isArray(s.agents) ? (s.agents as Record<string, unknown>[]) : []
28 const r: Vizinho[] = []
29 for (const w of s.workspaces as Record<string, unknown>[]) {
30 const id = w?.workspace_id
31 if (typeof id !== 'string' || !id || id === meuWorkspace) continue
32 const deles: AgenteVizinho[] = agentes
33 .filter(a => a?.workspace_id === id && typeof a.pane_id === 'string')
34 .map(a => ({
35 pane: a.pane_id as string,
36 estado: ehEstado(a.agent_status) ? a.agent_status : 'unknown',
37 titulo: typeof a.terminal_title_stripped === 'string' ? a.terminal_title_stripped.trim() : '',
38 }))
39 const agregado = deles.reduce<EstadoHerdr>((m, a) => (PESO[a.estado] > PESO[m] ? a.estado : m), 'unknown')
40 r.push({
41 workspace: id,
42 rotulo: typeof w.label === 'string' && w.label.trim() ? w.label.trim() : id,
43 estado: ehEstado(w.agent_status) ? w.agent_status : agregado,
44 agentes: deles,
45 })
46 }
47 return r
48}
49
50/** O estado de cada pane vizinho, para comparar com a próxima leitura. */
51export const estadosDe = (vs: Vizinho[]): Record<string, string> => Object.fromEntries(vs.flatMap(v => v.agentes.map(a => [a.pane, a.estado])))
52
53/**
54 * Os agentes que acabaram de ficar blocked ou done. Na primeira leitura (antes null) não há aviso: o que já estava
55 * parado quando a sessão abriu não é novidade.
56 */
57export function mudancas(antes: Record<string, string> | null, vs: Vizinho[]): { rotulo: string; pane: string; estado: 'blocked' | 'done' }[] {
58 if (!antes) return []
59 return vs.flatMap(v =>
60 v.agentes
61 .filter(a => (a.estado === 'blocked' || a.estado === 'done') && antes[a.pane] !== a.estado)
62 .map(a => ({ rotulo: v.rotulo, pane: a.pane, estado: a.estado as 'blocked' | 'done' })),
63 )
64}
65
66/** A frase do aviso: curta, para o toast do operador e para o modelo. */
67export function textoAviso(m: { rotulo: string; pane: string; estado: 'blocked' | 'done' }): string {
68 return m.estado === 'blocked'
69 ? `${m.rotulo} (${m.pane}) está esperando o operador`
70 : `${m.rotulo} (${m.pane}) terminou`
71}
72
73/** O que vai ao modelo: os avisos e a regra de que a sessão do outro workspace é do operador. */
74export function contextoAvisos(pendentes: string[]): string | undefined {
75 if (!pendentes.length) return undefined
76 return `[herdr] ${pendentes.join('; ')}. A sessão de outro workspace é do operador: avise-o, não responda nem aja nela.`
77}
78
79export const arquivoVizinhos = (home: string, pane: string) => `${home}/.claude/herdr/vizinhos/${encodeURIComponent(pane)}.json`
80
81export const conteudo = (em: string, workspace: string, vizinhos: Vizinho[]) =>
82 `${JSON.stringify({ versao: VERSAO_VIZINHOS, fonte: 'herdr', em, workspace, vizinhos }, null, 2)}\n`
83
84// Modo habilitado (ACIONAMENTO.md): dentro do herdr, todo pedido lembra o agente de pensar o trabalho em frentes e
85// mostrar o disparo de cada uma. A regra inteira vai no primeiro pedido e depois de compactar; nos outros, a curta.
86export const REGRA_HABILITADO =
87 '[herdr] habilitado (herdr-vizinhos/ACIONAMENTO.md): planeje em frentes (workspace = projeto, tab = frente, pane = perfil de agente). ' +
88 'Com duas ou mais frentes, decida e mostre no plano, por frente: paralelo ou fila, o meio (sessão, subagente, workflow ou pane do herdr) ' +
89 'e o harness (claude, codex…). Frente delegada a um pane leva briefing (ferramenta briefing: escrever antes de abrir o pane, ' +
90 'conferir o escopo antes da prova). A sessão de outro workspace é do operador.'
91export const REGRA_CURTA = '[herdr] habilitado: plano com 2+ frentes declara, por frente, paralelo ou fila, meio e harness.'
92
93export const regraHabilitado = (inteira: boolean) => (inteira ? REGRA_HABILITADO : REGRA_CURTA)
94types/index.d.ts 11 lines1export type EstadoHerdr = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'
2export type AgenteVizinho = { pane: string; estado: EstadoHerdr; titulo: string }
3export type Vizinho = { workspace: string; rotulo: string; estado: EstadoHerdr; agentes: AgenteVizinho[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 /** O último estado de cada pane vizinho (para ver a mudança), os avisos ainda não entregues ao modelo e os vizinhos escritos. */
8 'herdr-vizinhos': { estados: Record<string, string> | null; pendentes: string[]; vizinhos: Vizinho[]; regraEnviada: boolean }
9 }
10}
11