SLOPSHOPPER

herdr-vizinhos

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…

newguardtoastprompttoolprocess
v0.3.0MITupdated 2026-10-16aiob3/claude-grounds/herdr-vizinhos
A shopper browsing a rack in a slop shop
README

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

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

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

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

O que você ganha

A faixa: o contexto do projeto à vista

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

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

A prova: o critério que se reaplica sozinho

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

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

O registro: nada se perde, nada infla

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

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

Do lado do agente

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

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

Instalar

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

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

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

Primeiros cinco minutos

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

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

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

O ciclo, numa sessão real

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

1. Declarar

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

2. Acompanhar

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

3. Provar

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

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

4. Registrar e fechar

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

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

5. Continuar

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

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

Por que os dois juntos

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

Construído com ele mesmo

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

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

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

Sem hooks para configurar

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

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

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

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

Quanto custa provar

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

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

O resultado.

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

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

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

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

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

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

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

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

Limites e próximos passos

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

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

Avaliar

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

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

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

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

Source 4 files
hooks/register.ts 170 lines
1// 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}
170
hooks/briefing.ts 151 lines
1// 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}
151
hooks/vizinhos.ts 94 lines
1// 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)
94
types/index.d.ts 11 lines
1export 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