Histórico de fases, parte 4: do runner local em diante
Registro de 11/09/2026: descreve o Brabo daquela data
Do runner local (ADR 0102 e 0103) em diante, mais a seção migrada do índice do CLAUDE.md, onde estão as fases 28, 29 e 30. Texto movido sem reescrita do Histórico de fases. O que vale hoje mora nas RNs e nos ADRs.
Runner local — execução na máquina do usuário (RN-418/419/420, ADR 0102/0103)
Pedido do dono do produto: agente rodando na MÁQUINA DO USUÁRIO, na pasta local do projeto, pelo terminal padrão do usuário — mais terminal interativo de verdade na aba Code. Duas mudanças, uma dependendo da outra.
Política (ADR 0102 — decisão GLOBAL, confirmada explicitamente pelo dono
do produto depois de um aviso automático de segurança sobre a mudança):
git push/PR/deploy (RN-106) e sudo/doas (novos) saem de deny
incondicional e viram TETO ABSOLUTO — require_approval incondicional,
no MESMO bloco e MESMO padrão dos tetos vizinhos (current.policy === 'auto_approve'), nunca auto-aprovável por agent_autonomy(curinga "*")
nem por permissions.json. A fresta que o deny original tapava à força
foi fechada na FONTE: ApproveAlwaysActionUseCase/patternForAction
recusam gravar padrão em allow pra ação com efeito externo git ou
comando privilegiado — "sempre permitir" aprova só a instância, nunca o
padrão, pra esses dois casos. Sem essa segunda metade, o teto seria
decorativo. sudo/doas casam por VERBO em comandoPrivilegiadoNoComando
(external-effect.ts), varrendo todos os segmentos do comando, mesmo
princípio de efeitoExternoNoComando.
Runner (apps/runner, novo workspace Node/TS, ADR 0103): CLI
(brabo-runner --project <id> --dir <pasta> --token brb_...) que o
usuário roda na própria máquina, conecta no ENGINE via canal Phoenix NOVO
(/runner,
tópico terminal:<projectId>) autenticado por ticket de USO ÚNICO (mesmo
padrão RN-108, mas ticket EMITIDO PELO ENGINE — schema "engine",
migration própria — e pedido pela api via rota HTTP interna, invertendo o
fluxo do ticket de sessão). Executa exec (comando de agente JÁ APROVADO
— o roteamento em TerminalExecutor só acontece DEPOIS do pipeline de
decide()/proposed_action de sempre, nunca antes) no $SHELL do
usuário, e um modo PTY interativo pra aba Terminal (pty_open/pty_data/
pty_input/pty_resize/pty_close — o engine faz RELAY puro, nunca
interpreta os bytes). Só UM runner por projeto (Engine.Runners.Registry,
:global, exclusividade de :global.register_name/3); modo runner SEM
workspace verificado ou SEM runner conectado RECUSA explicitamente — nunca
cai no System.cmd/bind-mount, que não existe pra um projeto runner
(revisado pelo ADR 0104/RN-423, ver acima; o fallback pro caminho de
sempre continua valendo, mas só pra container/mounted). PTY é ação do
USUÁRIO autenticado, não passa por proposed_action, mas audita
(terminal.session.started/ended no event log, inclusive quando a aba
cai sem pty_close explícito).
A fronteira de segurança do runner NÃO é sandboxing — é autenticação
(o CLI apresenta um Personal Access Token da CONTA do usuário) + o
pipeline de aprovação de sempre + o consentimento de o usuário rodar o
CLI na própria máquina, com os privilégios dele. apps/runner/src/guard.ts
valida cwd dentro da raiz por resolução léxica, mas é declarado
BEST-EFFORT, não a garantia real.
PAT fechou a lacuna de token de automação (ADR 0105/RN-424..426). O
achado real da Onda 1 — "o produto não tem mecanismo de token de conta de
LONGA DURAÇÃO pra automação, então o runner replica o login do browser
(cookie/CSRF persistidos em ~/.brabo/runner-credentials.json)" — foi
fechado na Onda 2: personal_access_tokens (brb_…, hash HMAC-SHA256+
pepper via hashDeToken(), nunca argon2 — errado pra segredo de ALTA
entropia) é emitido em Configurações do projeto, escopado a UM projeto,
revogável, com expiração opcional. apps/runner/src/auth.ts perdeu por
completo login interativo, cookies e o arquivo de credenciais — só
valida formato e repassa --token/BRABO_ACCOUNT_TOKEN, NUNCA gravado
em disco pelo CLI. O PAT nunca autentica fora de
POST .../runner-ticket, por CONSTRUÇÃO (IS_PAT_ROUTE_KEY/
@RequirePatAuth() + PatAuthGuard) — nunca um branch no JwtAuthGuard
global, que deixaria RolesGuard autorizar o PAT pra qualquer rota do
papel do usuário.
maintainer revoga PAT de outro usuário (RN-427), fechando o item
que o ADR 0105 tinha declarado fora de escopo por ora (resposta a
incidente — dev desligado com token vazando). Extensão do mesmo modelo,
sem ADR novo: rotas SEPARADAS (GET .../personal-access-tokens/all,
DELETE .../personal-access-tokens/:tokenId/admin), escopo por
projectId em vez de userId — a autorevogação de cada usuário
(RN-426) não muda.
@xterm/xterm/@xterm/addon-fit (web) e phoenix/node-pty (runner)
são as quatro dependências novas — mesma régua de exceção do mermaid
(ADR 0068): import() dinâmico, sem eval/new Function confirmado por
grep no pacote instalado (não é garantia formal contra ofuscação, só
evidência forte — declarado como incerteza, não afirmado como certeza).
ADR 0104 — Onda 1 CONCLUÍDA (RN-421/422/423): os ADRs 0072 e 0103
nunca se falaram — RN-170 exigia bind-mount na criação, e o roteamento pro
runner (RN-420) reusava a mesma flag workspace_mode == 'local' sem
bind-mount nenhum, então usar o runner de verdade continuava obrigado a
passar pela validação de pasta montada. workspace_mode (2 valores) virou
execution_mode (container/mounted/runner, migração 0048) —
RN-170 (agora RN-422) passa a valer só para mounted; runner nasce com
caminho validado só LEXICAMENTE (sem I/O) e workspaceVerifiedAt: null,
promovido a verificado quando um runner conecta e confirma o caminho no
HOST (POST /internal/projects/:projectId/workspace-verification, RN-423)
— o runner é a FONTE DA VERDADE do caminho, sobrescrevendo o que foi
digitado no wizard. Engine.Actions.TerminalExecutor.decisao_de_execucao/1
ganhou QUATRO saídas: roteia só com workspace verificado E runner
conectado; sem qualquer um dos dois, RECUSA explicitamente — nunca cai no
System.cmd/bind-mount de mounted, que não existe pra um projeto
runner.
Achado da implementação, corrigindo o ADR (que não é editado — a
correção mora aqui e em
backlog.md):
a frase do ADR 0104 de que a conversão entre os três modos de projeto
existente "passa a ser permitida sem recriar o projeto" está INCORRETA.
UpdateProjectDto continua excluindo executionMode/workspacePath de
propósito — não é PATCH trivial (worktree, permissions.json e cache do
engine apontam pro escopo antigo). A Onda 1 entregou só o campo de três
valores na CRIAÇÃO; conversão de projeto existente é onda futura, com
desenho próprio, ainda não planejada. Backlog do runner: a Onda 2 (PAT,
ADR 0105) fechou o item que bloqueava npm publish @brabo/runner, e a
Onda 3 (ADR 0106) entregou a distribuição em si — tsup empacota
apps/runner num dist/index.cjs único (node-pty como external
obrigatório, binding nativo), publicado a cada tag final por um workflow
próprio (publish-runner.yml), paralelo a release.yml. Achado real
testado empiricamente: a guarda de auto-run de index.ts precisou de
realpathSync em process.argv[1] — sem isso, main() nunca rodava
quando o CLI era invocado pelo bin instalado via npm install -g
(symlink, que process.argv[1] nunca resolve por realpath mas
import.meta.url sempre resolve). Pendência operacional declarada: a
publicação de verdade exige o dono do produto criar o Automation Token do
npm e configurar o secret NPM_TOKEN — sem ele, o workflow avisa e pula,
nunca falha. Exclusividade por {project_id, machine_id} continua adiada
até segundo dev simultâneo real; guard.ts best-effort é invariante
REAFIRMADO, não lacuna. Ver
ADR 0104/
ADR 0105/
ADR 0106.
FERRAMENTA DE DESENVOLVIMENTO — pnpm bootstrap
Menu de terminal em scripts/dev/bootstrap.sh agrupando o que se faz no
dia a dia: Docker, K8s, Database e Test. Existe porque esses comandos moram
em TRÊS lugares que não conversam — package.json, Makefile e scripts em
deploy/k8s//docker/ — e conhecimento decorado apodrece.
Ele não reimplementa nada: cada folha chama o comando que já existe. O
modo --print-commands resolve a árvore inteira sem TTY e sem executar, e é
sobre ele que roda o teste (scripts/dev/bootstrap.spec.ts) — um TUI não se
testa por unidade, mas o mapeamento menu→comando sim, e é ele que erra.
Zero dependência nova; as cores saem de design/tokens.css em ANSI 24-bit
com degradação para 256 cores e para nenhuma.
A saída de um comando em execução é ROLÁVEL (roda do mouse em SGR, j/k,
PageUp/PageDown, G para voltar ao fim). O que faltava não era ler a roda: era
DESLOCAMENTO — tail -n só sabe mostrar o fim —, então a janela virou recorte
com sed -n 'a,bp', testável sem TTY por --print-window. Duas consequências
que o rodapé anuncia: rolar para trás CONGELA a janela (como o less +F, senão
o redesenho de 5 Hz desfaz a rolagem) e o rastreio de mouse, ligado só na tela de
execução, é desligado em TODA saída — \e[?1006l\e[?1000l em
restaurar_terminal, que o trap de EXIT cobre. O parser de escape é CSI
genérico (lê até o byte final): ler dois bytes fixos deixava o resto da sequência
no buffer, e como o menu trata [1-9] como escolha, um giro de roda disparava
itens do menu.
Três decisões registradas: Create provisiona do zero e Deploy publica
num ambiente que já existe (por isso só Deploy tem escolha por serviço);
no K8s só All funciona, e Api/Engine/Web aparecem DESABILITADOS em vez de
sumirem, porque o menu deve dizer o que o produto não faz; e
Database › Delete recria a extensão pgvector, já que
docker/postgres/init.sql só roda na primeira inicialização do volume —
um DROP SCHEMA puro faria a migração seguinte falhar, e como o engine
divide o mesmo banco, recuperar exige db:migrate E engine:migrate.
Docker › Reset total (scripts/dev/reset-total.sh) soma essa
sequência inteira numa folha só — preflight, up --build --wait (o
--wait do Compose v2.17+ é o "até tudo saudável", sem laço de saúde
escrito à mão), o mesmo DROP SCHEMA da Delete, db:migrate +
engine:migrate e o seed — com tela de confirmação PRÓPRIA
(confirmar_reset, exige digitar RESET), distinta da da Delete (que
exige o NOME do banco) e sem a precondição "postgres já de pé" da Delete,
porque é o próprio comando que sobe o compose do zero. O seed
(apps/api/src/db/seed.ts) ganhou um passo que ativa credencial de
provider a partir de .env, reaproveitando as MESMAS variáveis
<PROVIDER>_TEST_KEY que os smokes de LLM já usam
(apps/api/test/infrastructure/llm/*.smoke.spec.ts) — uma convenção de
nome só, dois consumidores; provider sem variável definida não entra, sem
erro, e a credencial fica no OWNER (RN-058).
Dois achados ao rodar o reset pela primeira vez, e não pelo item em si — nenhum dos dois é hipotético, os dois quebraram a execução real:
gemma:1bnão existe no registry da Ollama (manifest unknown— sógemma3:1bexiste), entãoollama-model-loadersempre falhava e o--waitnunca fechava verde. Corrigido nos seis lugares que carregavam o valor errado como CÓDIGO —docker-compose.yml/.prod.yml,.env.example,docker/ollama/pull-models.sh,scripts/dev/verificar-modelos-ollama.sh,deploy/k8s/base/ollama/job-model-loader.yamle a RN-415 emdocs/business-rules.md— sem tocar os ADRs 0099/0100 (aceitos) nem a narrativa histórica desta mesma seção do CLAUDE.md, que descrevem o que foi pedido/decidido na época, não o valor operacional corrente.- Mais sério, e mais antigo —
Database › Delete(item 3.4, existente desde a criação do menu) nunca dropava de verdade:DROP SCHEMA public CASCADEnão alcançaengine.*(Ecto/Oban vive em schema PRÓPRIO,engine) nemdrizzle.__drizzle_migrations(o controle do drizzle-kit vive em schema PRÓPRIO,drizzle). Resultado:mix ecto.migratebatia emduplicate_tablenas tabelas do engine que sobreviviam, epnpm db:migrate— pior, SEM erro nenhum — via o controle do drizzle-kit intacto, concluía que já tinha rodado tudo e não recriava NENHUMA tabela da api.Delete/Reset totalagora dropamengineedrizzletambém, nesta ordem, antes depublic.
Abas agrupadas, PRs project-wide, pasta local via Runner, carrossel do PO (2026-08-20)
Cinco ondas independentes, rodadas em paralelo por arquivo disputado, vindas
de uso real da tela de projeto — nenhuma planejada de antemão. A sexta onda
do mesmo programa (tradução completa de produto e documentação, i18n) é
maior que as cinco juntas; a FUNDAÇÃO dela (Onda 6a — mecanismo, sem
tradução de conteúdo ainda) já fechou: react-i18next+i18next na web
(en virou idioma default do app, pt-BR mantido — RN-425), coluna
locale em users embutida no payload de login/refresh, AccountPage
nova (/account) provando o mecanismo, e Docusaurus com
i18n.defaultLocale: 'en'/locales: ['en', 'pt-BR'] — o snapshot pt-BR
atual de docs/ já está preservado em
website/i18n/pt-BR/docusaurus-plugin-content-docs/current/. A Onda 6b
(extração em massa da interface + tradução de docs/) avançou bastante em
2026-08-22, em quatro frentes paralelas — ver CHANGELOG.md (Unreleased/
Documentação) para o detalhe de cada uma — mas AINDA NÃO fechou: faltam
docs/business-rules.md (só o front-matter foi traduzido; o corpo, com
centenas de RNs, continua 100% em português — é o maior arquivo da doc
inteira e cresce mais rápido do que dá pra traduzir de passagem) e uma
fatia residual de componentes .tsx menores. Quando fechar de verdade,
esta seção ganha o resumo final e o restante do CLAUDE.md (Stack,
"Documentação") é revisado por completo para registrar inglês como idioma
primário de verdade.
- Régua de abas agrupada: 11 abas soltas viraram 6 no topo — Visão
geral, Agentes ▾ (Executores, Criativo, Chat, Insights), Dev ▾
(Código, PRs, Aprovações), Documentação ▾ (Backlog, Arquitetura),
Gastos, Configurações — via
GroupedTabsnovo (apps/web/src/components/ ui/GroupedTabs.tsx), construído POR CIMA doTabsgenérico, que continua sem grupo pras outras telas.ChaveDeAbacontinua flat — só a apresentação ficou agrupada, pra não mexer no deep-link?tab=. Chat e Chat RAG viram UMA aba (ProjectChatShell.tsx) com controle segmentado interno ("Conversar"/"Buscar") — a distinção de negócio entre os dois (RN-202: conversar ativa agente e gasta a chave do owner por turno; RAG é leitura sobre índice, sem agente) não mudou, só o contêiner de UI. - Aba PRs, project-wide:
ProjectApprovalsTabescopava "PRs em revisão" à sessão mais recente — a revisão de uma PR de sessão anterior sumia da tela assim que uma sessão nova propunha outra. A abaprsnova lista direto do provider de git (nunca por sessão) e cruza comproposed_actionpendente viafindPendingByProject, novo, ao lado do método já existente escopado por sessão — decisão usa osessionIdda PRÓPRIA ação, nuncalatestSession(RN-423). É a primeira produtora real degit_mergepela UI; a trava de branch protegida (RN-154) continua absoluta e intocada — botão "Merge" só PROPÕE, a aprovação humana continua sendo o único jeito de executar. - Aba Arquitetura: extraída da Visão Geral (module_map, diagrama C4,
ADRs, pendências), que fica com um resumo condensado + link. Primeiro
lightbox do design system —
Modalganhousize="full"— pra ampliar o diagrama C4 (SVG, sem perda de qualidade) (RN-424). - Navegação de pasta local via o Runner (RN-422, ADR 0104): revisa a
ADR 0072 (que tinha recusado seletor de pasta explicitamente) SEM
editá-la — a navegação existe, mas pelo Runner, que já roda com o
privilégio real do usuário na máquina dele, nunca pela api enumerando o
filesystem do container. Dois eventos novos no canal já existente
(
fs_list_dir/fs_home_dir), relay puro do engine, mesmo padrão do PTY.FolderBrowserModalfunciona onde o projeto já existe. Na criação (NewProjectWizard.tsx), o gap ficou declarado até o ADR 0108: no modomountedo projeto ainda só nasce na confirmação (a validação de caminho toca disco ali), mas no modorunner"Procurar pasta..." passa a criar o projeto ANTECIPADAMENTE — reusado por snapshot de identidade em vez de duplicado a cada clique — pra poder ancorar o ticket do canal antes da confirmação (RN-437, ADR 0108).RunnerOnboardingPanelnovo (compartilhado com a aba Terminal) substitui o<code>cru de antes.apps/runnerganhou README — publicar de verdade no npm/empacotar binário assinado continua fora do escopo, declarado no README. O explorador de pasta em si virou três colunas (atalhos, lista com um clique seleciona/duplo clique entra, painel de detalhes — RN-436), seguindo a referência visual do dono do produto. - Bug do carrossel do PO corrigido (RN-421): a leva de promoções
pendentes dependia de scan sobre os últimos 200 eventos
(
useSessionEvents) — numa sessão longa, a proposta saía da janela e o carrossel degradava pra card único ou sumia. Mesma classe de bug que a RN-180 já tinha corrigido praContextAside; a fonte agora éuseBacklog(completo, sem janela), com fallback por história quando o backlog ainda não respondeu.
Handoff manual a agente à escolha (RN-440/441, ADR 0109)
Fecha o item de backlog aberto desde a FASE 13c. CreateHandoffUseCase
continua sendo o ÚNICO lugar que grava toAgent (ADR 0038) — ganhou só um
actor?: Actor opcional, default {kind:'agent', id: fromAgent} de
sempre. RequestManualHandoffUseCase (rota nova, POST .../sessions/:sessionId/handoffs, papel developer) valida toAgent
contra addressableAgents() — catálogo FECHADO (leads de área ∪
SOLO_CONVERSATIONAL_AGENTS), mais estrito que assertHandoffTargetAllowed
(que só recusa subagente) — resolve fromAgent pelo agent.activated
mais recente da sessão (sentinela "usuario" sem nenhum), e grava
actor: {kind:'user'}. O handoff nasce offered igual a um automático:
o card de aceite já existente (SessionPage.tsx) o pega sozinho, zero
mudança no caminho de aceite.
Investigação ANTES de codar achou ux-designer (ADR 0087) na MESMA
situação do Staff (ADR 0088) que motivou o item: cláusula de message/2
pronta em agent_command_controller.ex, nenhum caminho humano até ela.
Os dois entraram em AGENTES_DE_CHAT na mesma mudança — infra/qa
continuam de fora, mesmo padrão já documentado ali (leads sem kickoff/1
nem cláusula de message). Declarado, não escondido: o mirror de
SOLO_CONVERSATIONAL_AGENTS do lado web (apps/web/src/lib/agents.ts)
NÃO é cruzado por teste com o da api — divergir produz, no pior caso, uma
opção velha no seletor que o backend ainda recusa com 400.
Budget por área — fecha o corte do ADR 0038 (RN-443, ADR 0110)
Item aprovado do backlog ("Older backlog", docs/explanation/backlog.md).
agent_areas ganha budget_micros/spent_micros, espelhando exatamente
max_parallel — mesma linha, mesmo dono da decisão (maintainer), sem
tabela nova. É um TERCEIRO teto ADITIVO ao lado de projeto e sessão
(CheckBudgetGateUseCase checa os três em paralelo, qualquer um bloqueado
já recusa), não a cascata de binding de modelo do ADR 0064 (sessão > agente > área > projeto > workspace, "o mais específico vence") — os dois
mecanismos compartilham a palavra "área" e mais nada; não confundir os
dois é o ponto central do ADR 0110, do mesmo jeito que RN-101/ADR 0063 já
separa a visão do owner da visão do membro no relatório de gasto.
spent_micros soma SEMPRE que o ator pertence a uma área (lead ou
membro, via areaDo — função pura, sem tocar banco pra ator sem área),
com ou sem budget_micros configurado (null é o default, sem teto).
Binário standalone do runner (2026-08-23, RN-451/452, ADR 0112)
Item de backlog do ADR 0104 ("standalone binary, pkg/bun build --compile"), companion do ADR 0106 (distribuição via npm). Decisão
explícita do dono do produto: binário ÚNICO de verdade, matriz completa
com Windows desde a v1 — a alternativa mais barata ("binário + pasta
node-pty-native/ ao lado") foi oferecida e recusada.
bun build --compile embute o .node nativo do node-pty via
with { type: 'file' } e o extrai pra um diretório TEMPORÁRIO REAL em
runtime (native-pty-loader.ts) — necessário porque node-pty resolve o
próprio addon por um require() de caminho COMPUTADO
(lib/utils.js#loadNativeModule), que o Bun não consegue embutir sozinho
(build passa, runtime lança Cannot find module, testado antes de
desenhar a correção). Cinco plataformas
(linux-x64/linux-arm64/darwin-x64/darwin-arm64/win32-x64), cada
uma construída no seu runner NATIVO
(.github/workflows/build-runner-binaries.yml, gatilho de tag final,
mesmo padrão de publish-runner.yml), binário anexado à Release já
existente.
Validação honesta, por plataforma: só linux-x64 foi executado de
verdade neste ambiente (build:bin+smoke:bin verdes, repetidamente —
foi rodando o binário real que os dois bugs abaixo apareceram). As outras
quatro — Windows em especial, com TRÊS arquivos nativos em vez de um —
foram raciocinadas lendo o código-fonte do node-pty, nunca executadas
numa máquina real daquele SO; a primeira execução real delas é a PRÓXIMA
tag de versão, não esta PR. Nenhum fallback por plataforma foi aplicado
preventivamente — nada nesta investigação PROVOU que alguma das cinco não
funciona assim; se uma tag real mostrar que sim, isso vira item novo, não
suposição de hoje.
Dois defeitos achados só rodando o binário compilado, não lendo a API:
--external node-pty precisa estar no comando do bun build mesmo sem
import estático nenhum de node-pty no código — o await import(...)
de fallback (usado só fora do binário) é descoberto e embutido pelo Bun de
qualquer forma, e falha em runtime com erro enganoso; e a guarda de
auto-run (ADR 0106) precisou de um segundo ramo — process.argv[1] dentro
do binário é um caminho VIRTUAL (/$bunfs/root/...) que realpathSync
não alcança, lançando ENOENT fora de qualquer try/catch antes de
main() ser tentado.
Converter execution_mode de projeto existente (RN-447..450, ADR 0111)
Fecha a correção que a Onda 1 do runner (ADR 0104) tinha registrado em
docs/explanation/backlog.md: o item 4 daquele ADR dizia que converter
execution_mode entre container/mounted/runner num projeto que já
existe "passa a ser permitida sem recriar o projeto", e isso era falso até
aqui — UpdateProjectDto continua excluindo os dois campos de propósito.
PUT projects/:projectId/execution-mode (maintainer,
ConvertProjectExecutionModeUseCase) é rota DEDICADA, não um PATCH
afrouxado: ela orquestra a migração em vez de só trocar a coluna.
Recusa com 409 enquanto qualquer dev agent do projeto (em QUALQUER sessão,
lido direto de engine.dev_agent_states por project_id, mesmo caminho da
RN-409) não está idle — idle_tripped CONTA como ativo aqui, ao
contrário do critério de "online" da RN-409, porque o agente ainda tem
workspace_root capturado em memória esperando desbloqueio humano
(RN-447). PermissionsFileStore ganhou move(from, to): o conteúdo do
permissions.json nunca muda, só o caminho físico, porque projectScopeRoot
deriva do par (modo, caminho) (RN-448). Saindo de container com container
provisionado, o ciclo de vida (ADR 0081) é levado a removed — via
stopped primeiro se estava running — ANTES da coluna mudar, porque
RegistrarTransicaoDeContainerUseCase recusa transição fora do modo
container; entrar EM container nunca auto-provisiona (RN-449).
workspaceVerifiedAt zera em toda conversão real, inclusive voltando para
runner com caminho de aparência igual (RN-450). Mesmo (modo, caminho) de
hoje é no-op — nem checa dev agent ativo.
Consequência declarada, não escondida: a conversão NUNCA copia o código
já escrito entre a raiz antiga e a nova. Isso é seguro para o que já foi
COMMITADO — o engine deriva o worktree do bare repo do zero
(Engine.Actions.Workspace.ensure!/4) sempre que a pasta de destino ainda
não está marcada pronta, então o próximo agente reconstrói a árvore de
trabalho na raiz nova a partir do git de verdade. O que NÃO sobrevive é
diff NÃO commitado no worktree abandonado — ele fica órfão no disco antigo,
nunca migrado nem apagado. O teto de dev agent ativo (RN-447) reduz o
risco (nunca converte com um agente working) mas não elimina: um agente
idle entre tasks pode estar sentado sobre um diff sujo, e nada aqui
inspeciona limpeza de working tree antes de converter.
Pasta local anexada como referência do Chat RAG (2026-08-23, RN-455..457, ADR 0113)
Pedido do dono do produto: anexar uma pasta da PRÓPRIA máquina do usuário a
um projeto como referência de LEITURA para os agentes — sem o CLI
brabo-runner (ADR 0103). Explicitamente NÃO é execução/workspace de
projeto: execution_mode: runner (ADR 0104) precisa do CLI porque precisa
de um caminho de HOST real pro container rotear comando; isto é mais
parecido com "anexar arquivos a uma conversa". O navegador lê o CONTEÚDO
(File.text()) e o caminho RELATIVO (File.webkitRelativePath) dos
arquivos escolhidos — nunca um caminho absoluto de host (a API de File
não expõe um, pra nenhum site), e por isso o ADR 0113 é explícito que NÃO
é uma revisão do que os ADRs 0072/0107 recusaram: aqueles recusavam
RESOLVER um caminho de host a partir do navegador; aqui não existe caminho
nenhum a resolver, só bytes que o navegador já tinha o direito de ler.
chunks.scope (ADR 0079/0080) ganha um QUARTO valor, 'local' (migração
0052, ALTER TYPE ... ADD VALUE, aditivo — os dois CHECK da migração
0045 já são escritos como "é session ou não é", então local cai do
mesmo lado de docs/adr sem CHECK novo). IndexLocalFolderUseCase
espelha IndexProjectDocsUseCase de perto — chunking Markdown-aware pra
.md/.mdx, RagEmbeddingService, chunk sem vetor em vez de falhar
quando o embedding cai (RN-233) — mas REJEITA (400) o upload inteiro
quando os tetos AGREGADOS estouram (quantidade de arquivos, bytes
somados), nunca trunca em silêncio: é um gesto ÚNICO do usuário com um
seletor de pasta na tela, não uma varredura em background. Arquivo
individual grande demais ou de extensão não reconhecida só é PULADO
(filesSkipped), nunca derruba o lote (RN-456). origemDoChunk não
precisou de nenhum ramo novo: local carrega sourcePath como
docs/adr, cai no kind: 'file' que a citação já sabia renderizar.
Nunca entra no "Reindexar agora" genérico (RN-457): ReindexProjectUseCase
reindexa lendo de uma fonte que o SERVIDOR consegue revisitar (repositório
do projeto, event log); local não tem fonte nenhuma pra revisitar — o
texto só existe no navegador de quem anexou. Reanexar a pasta (novo
upload em POST .../rag/local, maintainer) É o mecanismo de
resincronizar, um botão deliberadamente separado, com comentário cruzado
nos dois casos de uso apontando um pro outro e pro ADR 0113, pra uma
"correção" futura não religar os dois e apagar o material do usuário. A
cobertura (RagCoverage.local) é forma PRÓPRIA — não há "total no
repositório" pra comparar — e lastAttachedAt é a ÚNICA exceção real à
regra "nunca Xmin" (RN-237): um MAX(chunks.created_at) de verdade, não
chutado (RN-458).
Faixa de atividade do turno — o fio só recebe a resposta no fim (RN-459/460)
Pedido do dono do produto: a tela de Sessão passa a mostrar, ACIMA do
composer, uma faixa que narra em linguagem humana o que um agente
conversacional (Criativo, PO, Arquiteto, Dev Lead, UX Designer, Staff)
está fazendo DURANTE o turno — referência visual: a linha de status do
Claude Code. O fio só recebe a bolha de resposta DEPOIS que o turno
termina; a regra é do CHAT (canal Phoenix) e vale só pros seis
conversacionais — o chat consultivo sem agente ativo (SSE,
streamChatMessage) continua com a bolha de streaming de sempre,
intocada.
Mecanismo: os seis servers já emitiam tool.call DURÁVEL; passam a
também broadcast(state, "tool.call", %{tool: name, agent: @agent}) —
EFÊMERO, sem args (RN-096/RN-412) — pro canal entregar em tempo real.
Não há behaviour/macro compartilhado entre os seis dispatch_tool
(quatro formas estruturais distintas, confirmadas por leitura completa) —
a mudança foi seis edições adaptadas à forma local de cada um, não uma
refatoração. Do lado web, um reducer PURO
(reduzirAtividadeDoTurno/lib/atividade-do-turno.ts) acumula o
agent.delta como texto corrente; ao chegar tool.call, arquiva o
corrente não-vazio como linha de narração, zera, e sempre adiciona uma
linha de ferramenta com fraseDaFerramenta(tool) (dicionário das 19
ferramentas, lib/narracao-de-ferramentas.ts, sem RN própria). Uma flag
PRÓPRIA (turnoViaCanal, nunca derivada de statusAgent/streamingAgent,
que passam por janelas legitimamente null no meio do turno) decide se a
faixa aparece ou a bolha antiga — nunca as duas.
Regra de apresentação (histórico): agruparNarracoesDoTurno, nova
passada pura depois de afundarDesfechos (RN-172) no mesmo useMemo —
agent.response consecutivas do mesmo turno+autor viram um
Disclosure compacto ("Passos do turno · N"), só a última fica intacta
fora dele. turno === 0 (o prólogo sentinela de turnoDoSeq) fica de
FORA do agrupamento — fixtures antigas empilham agent.response sem
fronteira de turno pra testar outro mecanismo (RN-138/RN-177), e sem a
exclusão o agrupamento novo produziria Disclosure dentro de
Disclosure ali. afundarDesfechos em si não mudou.
Achado no caminho, escopo estendido por decisão do dono do produto: a
investigação achou que QUATRO dos seis servers (Arquiteto, Dev Lead, UX
Designer, Staff) terminavam CALADOS no teto de iterações —
defp run_turn(state, remaining) when remaining <= 0, do: state, sem
evento nenhum, contradizendo a regra permanente de que laço de agente não
termina calado. Só o PO já emitia toolloop.limit_reached (RN-166); o
Criativo diverge deliberadamente com agent.error (decisão documentada
no próprio código). Os quatro corrigidos passam a emitir
toolloop.limit_reached, mesmo evento do PO (RN-459).
Migrado do índice do CLAUDE.md em 2026-09-11
As entradas abaixo vinham da tabela-índice do CLAUDE.md e não tinham
seção própria neste arquivo — a narrativa delas existia num lugar só. Foram
movidas sem reescrita, na ordem em que estavam, quando a tabela foi reduzida a
índice. O que estiver aqui é histórico; o que vale hoje mora nas RNs, nos ADRs
e na seção "Estado atual e aberto" do CLAUDE.md.
PROGRAMA 16–26 (ADR 0060–0072)
Handoff de design adotado; 8 telas; kind de sessão; eventos paginados; Gastos; modelo por área; container por projeto (25b cortada); aba Code
pnpm bootstrap
Menu de terminal sobre comandos existentes; Reset total
Ollama nativo / pull de Hugging Face (RN-461..463, ADR 0114–0115)
Bootstrap dev pergunta uma vez e persiste o modo do Ollama em .env;
navegador de modelos do Hub com pull em duas etapas e allowlist de publisher
oficial
Lockfile próprio do website (ADR 0117)
website/ sai do workspace pnpm da raiz — lockfile e overrides de segurança
isolados, pnpm audit do produto para de reportar a árvore do Docusaurus
Configuração automática do runner pelo navegador (RN-464..466, ADR 0118)
Chave de dispositivo Ed25519 gerada no navegador, aditiva ao PAT; proxy do
binário via GitHub Releases; File System Access API grava a pasta configurada,
com fallback de download fora do Chromium; --project/--dir/--token viram
opcionais no CLI
Imagens publicadas no GHCR (ADR 0119)
release.yml publica as quatro imagens a cada tag final e registra os digests
em .release/images.json; overlay de produção deixa de apontar para um
placeholder
Schema por agregado (ADR 0121)
db/schema.ts vira barrel; as 51 tabelas e 34 enums viram 16 arquivos sob
db/schema/, um por agregado de domain/*
SessionPage.tsx em 5 PRs mecânicos (ADR 0122)
3 807 → 2 661 linhas; timeline/turno, StorySlide, StructuredQuestionCard,
árvore de backlog + ContextAside, hook useSessionReadiness — cluster do
canal de turno e ProjectSettingsTab.tsx seguem fora, declarado
Hook do canal de turno do SessionPage.tsx (ADR 0124)
2 661 → 2 479 linhas; PR mecânica de dedup
(iniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista)
seguida da extração de estado + efeito do canal Phoenix para o hook
useTurnoDoAgente (lib/session-turno.ts) — fecha o item que a ADR 0122
deixou declarado em aberto; ProjectSettingsTab.tsx segue fora, decisão
separada
ProjectSettingsTab.tsx por seção (ADR 0125)
2 532 → 77 linhas; as 17 seções viram um arquivo cada sob routes/settings/
(+ shared.ts com os DOIS helpers de mais de um chamador), e o arquivo antigo
fica no caminho como entrada e barrel — caminho e os 11 nomes exportados são
contrato de 3 testes. Fecha a ÚLTIMA metade da linha de dívida de
docs/architecture.md
Trilho vertical de navegação do projeto (ADR 0126, RN-201)
A régua horizontal de dois níveis (GroupedTabs) vira um trilho vertical com
os TRÊS grupos abertos ao mesmo tempo; geometria do trilho do CodeShell,
teclado portado, os 5 contadores seguem separados. Revisa a RN-201: a aba de
Código não recolhe mais a sidebar sozinha
Sumário ancorado de Configurações (CHANGELOG)
As 17 seções da aba ganham id, <section> com nome acessível e um sumário
em quatro grupos DENTRO da área de conteúdo — sem faixa vertical nova, moldura
segue nos 444px do ADR 0126. Registro em routes/settings/sumario.ts
(terceira lista do mesmo desenho de project-tabs.ts), deep-link ?section=,
spy por IntersectionObserver; só entra no sumário a seção que MONTOU
Padrão único de valor herdado (CHANGELOG)
As QUATRO redações de "não tem valor próprio" da aba Configurações viram dois
polos e um verbo, de uma fonte só (routes/settings/heranca.tsx): rótulo diz
o ESTADO, detalhe diz a CONSEQUÊNCIA. Vocabulário unificado, forma não — a
marca só entra onde o controle não mostra o estado sozinho
Salvar por seção em Configurações (RN-469)
Paralelismo e Teto de gasto trocam N botões de linha por UM da seção
(settings/secao-salvavel.tsx), com contagem de linhas pendentes e desfecho
POR LINHA — salvar é N chamadas, não uma transação, e a tela nunca afirma o
que não obteve. As três seções de autosave e Credenciais ficam de fora, cada
uma por um motivo declarado
Painel "precisa de você" (RN-467)
Chip no topo do projeto abre as CINCO filas de decisão num lugar só —
separadas, ordenadas por urgência, sem soma nenhuma (nem no chip). Aprovações
e merges decidem ali pelo ApprovalCard; as outras três levam à aba.
Pendência de arquitetura empresta a data da história e DIZ que emprestou
Cascata de modelo como cadeia visível (RN-470)
A coluna Origem para de imprimir o enum do banco e vira workspace › projeto › área › agente com quatro estados por nó (settings/cascata.tsx) — separando
os DOIS sentidos de origin: 'agent' sem tocar na api. O aviso de nível
descartado entra na cadeia; os três — ganham três textos. Fecha o canvas de
melhorias de UI (7 de 7 itens tomados)
Pasta antes do runner (RN-473/474)
A configuração do runner pelo navegador (ADR 0118) inverte a ordem:
showDirectoryPicker é o PRIMEIRO passo e o binário o ÚLTIMO, best-effort — a
release sem asset devolvia 502 antes do seletor abrir, e a pasta ficava
inalcançável. Falha do binário mantém os dois arquivos gravados e troca a
instrução pelo caminho npm install -g @brabo/runner. Depois da instrução,
EsperaDoRunner sonda workspaceVerifiedAt com três estados e teto
O kid na chave de dispositivo (RN-475)
O modo automático do ADR 0118 NUNCA autenticou: o navegador descartava o id
que o registro devolvia, e a JWK gravada nascia sem kid — o CLI a recusava
sempre. Registro e exportação viram UMA função
(registrarChaveEExportarPrivada), nos DOIS caminhos; e a recusa do CLI deixa
de ser indistinguível de "não há chave". O teste que deixou passar afirmava
que o arquivo fora ABERTO, nunca o conteúdo
Provisionamento que não fica calado (RN-477)
Duas falhas não viravam estado nenhum — step.check fora do try e a recusa
de createRepo ANTES de a linha de bootstrap existir —, e a tela pollava
"Iniciando…" para sempre sem botão. Causa raiz de tudo: /data/git-repos e
/data/project-workspaces nasciam root nas imagens de DEV, que o
Dockerfile.prod já sabia criar antes do USER
O arquivo de política e o escopo do terminal (RN-478)
projectScopeRoot tinha DOIS consumidores com necessidades opostas, e o modo
runner (sem bind-mount) os separou: o escopo segue apontando para o HOST, o
permissions.json passa a morar na raiz GERENCIADA. A ativação da execução
devolvia 500 (mkdir '/home/<usuario>'), e a LEITURA degradava calada — em
projeto runner o arquivo nunca existiu. Junto: erro tipado com 400 em vez de
Error cru, a Visão geral parando de engolir a mensagem da api, e a anotação
de OpenAPI que prometia 409 para dois casos que nunca foram 409
Aviso do passo humano antes do clique (RN-473/477)
O passo de terminal do runner era anunciado só no estado de sucesso; passa a
ser dito também no inicial, e o comando final ganha cd <caminho> quando dá
para afirmar que a pasta escolhida é a do projeto. Rótulo do botão NÃO muda —
ele fala da pasta, que é automática mesmo
Um modelo para todos os agentes (RN-476)
A tabela Modelos por agente ganha uma barra que aplica UM modelo aos 17 de
uma vez, gravando no nível do AGENTE (pelo projeto seria maintainer, e
mexeria no default de sessão). Primeira EXCEÇÃO à régua "valor nomeado salva
no onChange" da RN-469 — o seletor é argumento de ação, não configuração —,
com o desfecho em três estados da própria RN-469
Tetos de rebaixamento em project_members (ADR 0127, RN-472)
A sobreposição projectRole ?? workspaceRole FICA nos dois sentidos (é
capacidade, não bug); o que entra são dois tetos de 403 no caso de uso —
ninguém rebaixa o owner do workspace, ninguém rebaixa a si mesmo. As três
descrições de OpenAPI que a RN-471 declarou falsas passam a descrever o
código; o gate do Select é PR à parte, por a tela não ter como calcular o
primeiro teto
Telemetria de busca do RAG (RN-479..481)
A busca híbrida deixa rastro: rag_searches (com os pesos CONGELADOS na
linha) e rag_feedback (o voto útil/irrelevante, o único sinal de verdade).
TABELA e não só evento, porque session_events.session_id é NOT NULL e a
busca da aba não tem sessão — o evento rag.search/rag.feedback é NARRAÇÃO,
só quando há sessão. Ferramenta rag_feedback (:direct) nos seis agentes
que já tinham rag_search, e medir:rag para ler. NADA calibrado — esta
etapa só instrumenta
Broker de container (ADR 0130, RN-485/486)
Nasce apps/broker, o ÚNICO processo que fala com um daemon Docker no
servidor — e ele NÃO aceita especificação: recebe projectId + operação, LÊ a
decisão do Arquiteto da api e COMPÕE imagem, rede, recursos e o único mount. A
porta do ADR 0128 MOVE de apps/runner/src/ para packages/docker-port (os
dois consumidores a empacotam; a api não pode consumi-la). A rota de ciclo de
vida passa a devolver observado ao lado de registrado, sem fundir
Roteamento de módulos para infra (PR 1.4) (ADR 0131, RN-487)
Sétima ferramenta do Arquiteto, route_modules_to_infra: um item {modulo, imagemCandidata, porque} por módulo do module_map vigente, gravado como
artifact.module_routing (sem tabela, mesmo desenho de
artifact.project_image/artifact.c4_diagram). Ele CANDIDATA — a imagem de
cada item passa pela mesma validarDecisaoDeImagem de choose_project_image
—, e quem ELEGE entre as candidatas é a Infra, num PR à parte (1.5, fechado
logo abaixo)
Golden-set de acerto do RAG (ADR 0132, RN-490)
Molde do golden-set do QA (ADR 0123) aplicado à busca híbrida: como o
julgamento não mora no engine (é HybridSearchUseCase, na api),
seed-golden-set-rag.ts provisiona UM projeto com corpus real curado (22
arquivos de docs/) E roda a busca para 17 perguntas compostas de RNs/ADRs
reais; rag_golden_test.exs só invoca o script e aplica o piso. Critério de
acerto — caminho de arquivo, top-5, nunca chunk exato/rank 1 — é função pura
testada. Gate novo rag-acertivo, warn (sem CI com LLM). Medido de verdade,
duas vezes, deterministicamente: 17/17 no top-5
Dev agents executam DENTRO do container real (PR 1.6) (ADR 0134, RN-492/493)
Fecha o que a RN-491 declarou pendente: Engine.Actions.TerminalExecutor
ganha a QUINTA saída, :executar_no_container — projeto execution_mode: container com uma linha REGISTRADA running
(Engine.Containers.ProjectContainerLifecycle, leitura direta, mesmo padrão
de Engine.Projects.Project) — e o comando atravessa engine → api (POST internal/projects/:projectId/container-exec,
ExecutarComandoNoContainerUseCase) → broker (ContainerBrokerPort.exec, o
PRIMEIRO chamador real da última das cinco operações do ADR 0128/0130),
rodando via docker exec em vez de System.cmd local. cwd é traduzido do
caminho de HOST pra dentro de /work no engine, ANTES da chamada — confirmado
em código que é o MESMO diretório físico que Workspace.ensure!/4 já escreve
(o worktree do dev agent não muda de lugar nem de mecanismo).
BrokerRecusouError/BrokerIndisponivelError/falha de transporte viram
failed_result normal, nunca crash nem fallback silencioso pra fora do
container (RN-486: running registrado nunca garante container de pé).
Terminal DENTRO do container ganha PISO de auto-aprovação
(ProposeActionUseCase/decide.ts, containerExecutionActive) — os cinco
tetos absolutos e o escopo léxico de sempre continuam rodando por cima, byte a
byte
O runner sobe o container do projeto (PR 1.3, Parte 1 fora de ordem — fecha a metade que a RN-494/PR 1.7 tinha deixado declarada) (ADR 0137, RN-497)
container_start/container_stop/container_remove ganham SEGUNDO caminho
de execução, ramificado por executionMode: container segue pelo broker
(inalterado); mounted/runner passam a pedir ao RUNNER conectado, via TRÊS
pares novos no canal Phoenix (container_start/_result,
container_stop/_result, container_remove/_result, mesmo molde de
exec/exec_result) — EngineWeb.ContainerCommandController repassa pro
RunnerRouter, que o runner atende com DockerViaCli (o Docker DELE,
@brabo/docker-port, que ganha uso real além de --self-test-docker). A
imagem é LIDA (ObterSpecDeContainerUseCase, o mesmo caso de uso do broker),
nunca reeleita. "Sem runner"/"timeout" (RunnerNaoConectadoError) e "runner
recusou" (RunnerRecusouContainerError) viram failed nomeado, nunca
exceção. Engine.Actions.TerminalExecutor não ganhou saída nova — ele já
roteava todo comando de projeto runner conectado pro canal
incondicionalmente; a escolha host-vs-container é INTERNA ao runner
(EstadoDoRunner.containerAtivo, tratarExec roteia via docker exec com
cwd traduzido por troca de prefixo, cwdParaContainer). guard.ts/RN-434
passa a cobrir o bind-mount sem NENHUMA validação nova — o mount É a raiz já
confirmada no startup da CLI
O handoff da Infra podia ser aceito por tela nenhuma (D0) (RN-499)
offeredHandoff filtra o card do fio por AGENTES_DE_CHAT, que exclui
infra — e o filtro está CERTO (o engine não tem cláusula de message pro
Infra Lead; alargá-lo faria a tela oferecer um fio que não existe e o composer
mandar mensagem pro Criativo). O defeito era a consequência, que o comentário
do próprio código registrava como aceita: acceptHandoff tinha UM consumidor
só, atrás desse filtro, então o handoff de OfferInfraHandoffUseCase ficava
offered para sempre, o Infra Lead nunca era ativado,
propose_container_start nunca era chamado e NENHUM projeto de nenhum modo
chegava a ter container de pé. Correção: card acionável PRÓPRIO, fora do fio,
na faixa fixa entre a área que rola e o composer — a mesma que já hospeda o
handoff manual e se declara o lugar das ações de handoff que não são conversa
—, chamando o acceptHandoff que já existia, fechado pelo mesmo activeFor.
O handoff.offered da Infra continua NARRADO no fio como divisor mudo
A base única dos projetos montados (PR 1, BREAKING) (ADR 0141, RN-500)
Criar projeto mounted exigia uma linha de bind-mount escrita À MÃO em api
E engine mais um restart dos dois — que mata todo turno de agente, socket de
terminal e chamada de LLM em voo da instalação, para onboardar UM projeto.
Nasce BRABO_PROJECTS_BASE: UMA base, configurada uma vez pelo operador,
montada por IDENTIDADE ($X:$X) nos dois serviços, com todos os projetos
montados dentro dela e NADA editado por projeto. Identidade e não mountpoint
fixo porque workspace_path é digitado pelo usuário e mostrado de volta a ele
— e é o que faz projectScopeRoot/workspace_dir/2 continuarem certos sem
código novo. Variável PRÓPRIA (colisão de namespace com workspace_dir_name
UNIQUE faria git init na pasta de outro projeto; dono oposto; a base é
navegável e a raiz gerenciada não deve ser). AUSENTE é normal: projectsBase: null (GET workspaces/:id/projects-base, maintainer) e o modo não é
oferecido. E pnpm dev RECUSA subir com a base sobreposta ao checkout — a
única checagem possível, porque a api compara contra /workspace. Dois custos
declarados: symlink sob a base e uma base só
Container de projeto montado (PR 3) (ADR 0144, RN-503)
Projeto mounted não conseguia container NENHUM, por dois bloqueios
independentes — a api mandava todo modo não-container pro RUNNER (que exige
brabo-runner conectado) e o broker recusava não-container na fonte. Os
dois eram sobre GEOMETRIA, não sobre o nome do modo, e a base única do ADR
0141 mudou a geometria. A ramificação vira por DESTINO: container E
mounted → broker, só runner → runner (e container_stop/_remove mudam
JUNTAS com o _start, senão sobe no servidor e para na máquina do usuário). O
invariante do ADR 0130 não se mexe — nenhum caminho absoluto atravessa a rede:
o broker ganha uma SEGUNDA raiz (BRABO_PROJECTS_HOST_BASE) e a api manda um
localizador DISCRIMINADO (gerenciada |montada |indisponivel) dizendo
contra qual raiz o segmento relativo vale. TRÊS variantes porque
indisponivel tem dois consertos diferentes (runner × mounted legado fora
da base), e raiz que falta NUNCA é suprida pela outra. mounted ELEGE a
imagem como container — o broker compõe de artifact.project_image, então
eleição não gravada é eleição inerte (ADR 0133 num segundo modo)
Agentes de dev só depois do container (PR 7) (ADR 0143, RN-502)
Numa execução real do exp001, DEZ tasks de dev travaram de uma vez — e o
achado não foi a pasta que falhou, foi que nada ordenava container antes de
dev agent. Engine.Dev.AgentIo.try_claim/2 — o ponto ÚNICO de claim — passa
a consultar ProjectContainerLifecycle.running?/1 (o predicado que o ADR 0134
já tinha) ANTES de chamar a api: sem linha REGISTRADA running, o agente cai
em :idle, persiste e emite dev.blocked_by_container. No ENGINE e não só no
activate-execution da api porque a REIDRATAÇÃO chega ao try_claim/2 sem
passar por rota nenhuma (init/1 → finish_restart_recovery/1). :idle e
NÃO status novo, pela razão que o próprio docblock do try_claim/2 já
registrava desde a Fase 12b — é o único estado do qual um wake ainda resgata,
e todos os guards de handle_info/2 se apoiam nisso; quem distingue "fila
vazia" de "sem container" é o EVENTO, nunca o status. O wake vem pelo outbox:
RegistrarTransicaoDeContainerUseCase publica container.running na MESMA
transação que grava a chegada em running, num aggregate_type NOVO
(container, o terceiro que Engine.Outbox.Drain lê) em vez de pegar task
emprestado, com o PROJETO como aggregateId; a mensagem entregue é a {:wake, :became_claimable} que já existia. Segunda metade: TerminalExecutor para de
degradar calado — container sem running caía em System.cmd DENTRO do
processo do engine, e agora RECUSA, com mounted no mesmo ramo
Alarme de merge de esteira com destinatário (ADR 0139)
A regra "promoção é --no-ff" foi quebrada TRÊS vezes (#367, #394, #464 — a
terceira era o PR que consertava as duas primeiras). O achado: a detecção JÁ
existia e JÁ funcionou (o tag-release reprovou as duas primeiras com a
mensagem certa) — faltava para QUEM tocar, porque workflow de push que falha
numa permanente não tem PR onde ficar vermelho e o repo tinha zero issues.
tag-release ganha o job avisar, que abre issue (e comenta na já aberta,
nunca duplica) com github.token e nunca com o PAT — PAT inválido é uma das
falhas que ele reporta — e isso deixou de ser hipotético: o BRABO_BOT_TOKEN
expirou em 2026-09-03 à noite, reprovou duas runs do tag-release, e foi
rotacionado em 2026-09-04; a v4.0.0 carimbou e DISPAROU a Release, o que só
acontece com PAT válido (tag criada com GITHUB_TOKEN não dispara workflow).
A regra passa a cobrir dev de forma ESTREITA: só é defeito o PR que trazia
ARESTA NOVA (head era merge cujo segundo pai não estava na base), nunca o
squash de PR de trabalho, que é a convenção. Desligar squash no repo foi
MEDIDO e recusado (taxaria todo PR e mexeria no changelog.mjs, que roda
--no-merges)
O artefato de decisão dos conversacionais (Frente 3, fatia genérica) (RN-505)
decision_record reusa o padrão GENÉRICO de emit_artifact/ArtifactSchemas
(o mesmo de note/business_rule) em vez do dedicado de
project_image/c4_diagram — uma decisão é log append-only, nunca um
"vigente" que se substitui. PO, Arquiteto, Dev Lead, UX Designer e Staff
ganham a ferramenta (alias + tool_specs + run_tool/3 + a mesma frase no
system_prompt/1); o Criativo já tinha emit_artifact desde a Fase 3b e só
ganhou o tipo novo, sem mudança nele. Distinto de open_adr_pr (só o
Arquiteto, commit real + PR + aprovação humana): os dois COEXISTEM para
escalas diferentes de decisão. Fora de escopo, declarado: agentes de execução
(ToolLoop, sem harness comum) e a amarração com o Infra Lead — emitir
decision_record ao propor container_start_via_runner — que depende de uma
tool de outra frente do mesmo plano, em branch separada
"Sempre permitir" separado por Dev Agent de módulo (RN-509)
Clicar "sempre permitir" numa ação de um dev-<modulo> gravava em
permissions.json, escopo de PROJETO INTEIRO — liberava o mesmo comando pra
qualquer outro agente. Passa a gravar em agent_autonomy(projeto, agente, tipo), o MESMO mecanismo já usado pras 3 ações git seedadas na ativação da
execução. dev-lead (lidera a área, não é membro dela) e qualquer ator
não-modular continuam indo pro permissions.json de sempre; os dois tetos
absolutos (terminal com efeito externo/comando privilegiado,
container_remove) continuam recusando o clique inteiro ANTES do branch novo.
Sem migração: entradas antigas continuam em permissions.json, decisão
consciente
Docker vira pré-requisito real do modo Runner (RN-507/508, ADR 0145)
runner roteava todo terminal aprovado ao CLI assim que workspace verificado
- runner conectado batiam, sem checar container
running— sem Docker de pé, o runner caía sozinho no HOST puro, o MESMO fallback silencioso que o ADR 0143 já tinha fechado paracontainer/mounted.Engine.Runners.RunnerReadinessunifica as TRÊS pré-condições (verificado, conectado, containerrunning) com dois consumidores:TerminalExecutore a materialização do worktree do dev agent, que deixa de tentarFile.mkdir_p!/git initLOCAL contra o caminho do HOST (a lacuna aberta desde a RN-478) e passa a rodar DENTRO do container real, na máquina do usuário, pelo MESMO canalexec/exec_result(Engine.Actions.Workspace.RunnerGit, novo) —WorktreeManagerbifurca pelo mesmo critério, e o job periódico de limpeza PULA em silêncio um projetorunnersem runner pronto agora. O protocoloexecganhaenvopcional para a credencial de git (ADR 0056) viajar no ambiente do processo filho do HOST — mesclado sobreprocess.env, nunca repassado aodocker execnem logado.container_startdeixa de atenderrunner(o payload dela, com eleição de imagem, nunca fazia sentido lá); nascecontainer_start_via_runner— schema só comrationale, sobe a imagem já decidida —, com a tool nova do Infra Lead recusando LOCALMENTE (sem HTTP) antes de propor às cegas
RN-505 duplicada em dev, corrigida
O "artefato de decisão" e o "sempre permitir por Dev Agent" mergearam com 2
minutos de diferença, cada um alocando RN-505 sem ver o outro; "sempre
permitir por Dev Agent" foi renumerado pra RN-509 (docs/business-rules.md,
CHANGELOG, o comentário do approve-always-action.use-case.ts e do
ApprovalCard.test.tsx), liberando o anchor {#rn-505} pro
decision_record. Achado junto: a renumeração 505→507/506→508 feita durante a
resolução de conflito de outro PR (Docker/runner, ADR 0145) tinha ficado
INCOMPLETA — sobravam três menções a "RN-505" neste próprio arquivo, também
corrigidas pra "RN-507"
FASE 28 (sessão 5) — o join do agente local deixa de ser mudo (RN-514, ADR 0147 ponto 1)
O join do canal terminal:<projectId> era mudo dos DOIS lados — o runner
mandava {} e o servidor fazia _params —, então o servidor não tinha como
saber se o binário do outro lado entende uma mensagem nova, e o defeito era o
pior possível: a mensagem chega, o handler não existe e NADA acontece, sem
erro e sem log. Agora o runner DECLARA nos params
(Engine.Runners.Capacidades, vocabulário exec/pty/espelho) e o
servidor CONCEDE a interseção com o que CONHECE, em socket.assigns e NUNCA
em tabela (capacidade é propriedade daquela CONEXÃO). O runner declara só
exec e pty — declarar espelho sem implementá-lo é exatamente o defeito
que a negociação impede; ele entra como NOME do vocabulário e nada mais, e a
sessão 6 o implementa. Params ausentes/vazios são o binário LEGADO e concedem
{exec, pty} — FATO, não benevolência: são as duas funções com que o runner
nasceu, e recusar legado seria breaking/ e MAJOR. Desconhecido é IGNORADO
(runner mais NOVO que o engine conecta); exigida-e-não-declarada recusa o join
NOMEANDO a que falta, ANTES do Registry — mecanismo implementado e testado
que hoje NÃO dispara (runner exige exec, que todo binário tem; nada exige
espelho ainda), e isso é o desenho. A metade que entrega valor HOJE:
mensagem sem a capacidade é RECUSADA com resposta nomeada — exec responde no
formato de exec_result (126 + causa) em vez de o RunnerRouter esperar o
timeout, e pty_* vira pty_error na aba em vez de sumir. RunnerReadiness
byte a byte como está
FASE 28 (sessão 6, metade B) — o espelho passa a existir (RN-516, ADR 0147 pontos 2/3/4/8)
A capacidade espelho sai de nome do vocabulário e vira código nos DOIS
lados. O DESTINO viaja na CONCESSÃO do join (a resposta deixa de ser vazia
para o :runner: %{espelho: %{destino: ...}}), vive só naquela conexão e o
runner RECUSA mirror_sync para destino não concedido — nunca configuração
global nem variável, que faria o artefato do projeto B aterrissar na pasta do
A. mirror_path não-nulo passa a EXIGIR a capacidade, e esse é o PRIMEIRO
disparo real do mecanismo de recusa da RN-514: binário velho num projeto com
destino DEIXA de conectar, nomeado e fatal — opt-in, custo declarado no ADR.
espelho-guard.ts nasce IRMÃO de guard.ts (reusa os três helpers e a dupla
passada, herda a ressalva de TOCTOU por escrito) e recusa as três coisas do
ADR; achado implementando: realpathMaisProximo cai no ancestral, e como o
destino quase nunca existe, colapsar os dois lados faria /base-outra e
/base virarem / — a segunda passada resolve o ancestral e RECOLOCA o
sufixo. UMA direção e NUNCA apaga: arquivo apagado na origem PERMANECE no
destino (a pasta do usuário é acúmulo, não réplica). O que se copia é a LISTA
DO GIT (rastreados + não-rastreados-não-ignorados), sem lista de exclusão
própria; git que falha vira erro NOMEADO, nunca cp -r de plano B; repo git
aninhado (o worktree do dev agent) volta como UMA entrada e não é descido, e
symlink da origem é pulado. Predicado PRÓPRIO (Engine.Runners.Espelho), DUAS
pré-condições e NUNCA RunnerReadiness com flag — runner_readiness.ex fica
byte a byte. Gatilho: o COMMIT, momento nomeado, fire-and-forget e nunca
watcher; "fim de turno de agente" fica DECLARADO e não ligado, por não haver
enganche não-ambíguo
O broker que nunca subiu (correção da RN-512) (RN-512, ADR 0130/0146)
A RN-512 tirou o broker do profiles para ele estar de pé em dev, e ele NUNCA
subiu: corepack prepare rodava como ROOT no build (cache em /root/.cache),
o container roda como uid 1000, e sem cache o corepack tenta BAIXAR o pnpm num
serviço cuja única rede é internal: true — EAI_AGAIN registry.npmjs.org,
Exited (1) cinco segundos depois. Sem healthcheck, up --wait dizia
Healthy e o reset total anunciava "reset completo" com o processo morto;
enquanto houve profile, quem subia o broker era quem já estava olhando. A
correção tira o registry do RUNTIME e NÃO abre a rede (primeira das cinco
camadas do ADR 0130): COREPACK_HOME preparado no build com o dono do uid de
runtime, pnpm install como passo de BUILD, e — porque os três volumes de
node_modules cobrem o que a imagem instalou e o Docker só semeia volume
VAZIO — um entrypoint que os reconcilia contra uma cópia fora de /workspace,
carimbada pelo sha256 do lockfile do build (o mesmo carimbo AVISA que a imagem
ficou para trás, sem recusar subir). Healthcheck no serviço, de dentro do
container. Produção não sofria disto e não muda
FASE 28 (sessão 7) — o agente local vira serviço de usuário (RN-518, ADR 0147 ponto 5)
brabo-runner service install|uninstall|status, e o precedente era ZERO
(nenhuma menção a systemd/launchd em apps/runner/; o modelo era
foreground morto por SIGINT). Nível de USUÁRIO sempre — systemd --user/LaunchAgent —, e root é RECUSA e não aviso: o docblock de guard.ts
declara "roda com os privilégios DELE" como a premissa das três fronteiras
reais, e um serviço root deixaria a guarda best-effort de cwd como única
coisa entre um comando aprovado e o disco. Windows é recusa NOMEADA (terceiro
mecanismo, não variação dos dois). Uma unit por PROJETO porque o servidor já
impõe um runner por projeto — nomear pela pasta criaria duas units que nunca
sobem juntas, e a segunda apareceria como falha de conexão em vez de erro de
instalação; no Linux o arquivo vai para onde o systemd PROCURA
($XDG_CONFIG_HOME/systemd/user, senão ~/.config). Restart=on-abnormal e
NUNCA on-failure (no plist, KeepAlive/Crashed): exit 1 do runner é
recusa fatal de join (RN-514) ou teto de tentativas esgotado, e reiniciar
seria o laço que o CLI recusa fazer sozinho. Serviço autentica por CHAVE DE
DISPOSITIVO e nunca por token — gravá-lo numa unit o deixaria em disco, e este
CLI nunca gravou credencial em disco. uninstall remove os TRÊS lendo a pasta
da UNIT e nunca do cwd (sem unit, só com --dir — adivinhar apagaria a
chave de outro projeto), e DIZ que a chave saiu do disco e NÃO foi revogada no
servidor. status responde QUATRO estados com frase e código próprios
(0/3/4/5), e a primeira resposta vem do DISCO — por isso continua certa numa
máquina sem gerenciador; spawn que falhou nunca vira "o gerenciador disse
não". BRB-031 NÃO fecha aqui
A spec do runner ia sem projectId, e o caminho NUNCA subiu container (RN-508, ADR 0145)
container_start_via_runner (RN-508, ADR 0145) terminava failed em 100% das
vezes — "especificação de container recusada em projectId: esperava texto
não vazio, recebi undefined" —, e numa execução real do exp004 o que se via
era o EFEITO: dez tasks de dev sem nenhum agente reivindicando. Esse bloqueio
estava CERTO (RN-507 e RN-502/ADR 0143 recusam sem container running
REGISTRADO); o errado era o passo anterior.
EspecificacaoDeContainerParaRunner nasceu com NOVE campos e
especificacaoValidada (packages/docker-port) sempre exigiu DEZ — o dado
estava à mão em SpecDeContainer.projectId e só não era copiado; o engine
repassa o mapa OPACAMENTE, então nada se perdia no meio. Por contraste o
BROKER sempre funcionou porque ele BUSCA a spec na api e recebe o
SpecDeContainer inteiro — era por isso que só o modo runner quebrava. Três
suítes verdes não pegaram porque cada elo montava o próprio fixture: ninguém
testava a CORRENTE. Entra ela — o payload que o caso de uso REAL compõe
atravessando o validador REAL, e o CONJUNTO de campos travado (raizDoProjeto
é o único que a api não manda) — na suíte da api, por import RELATIVO, o único
lugar onde os dois lados existem vivos; o invariante
api-nao-consome-docker-port segue verde e foi ESTENDIDO para reprovar import
relativo a partir de src/. Irmãos conferidos e CERTOS:
container_stop/container_remove mandam só workspaceDirName, que é tudo
que nomeDeWorkspaceValidado pede
A pausa que não podia ser desfeita (RN-540)
Anamnese e Psicólogo estavam PAUSADOS desde 2026-08-10 e três lugares chamavam
a pausa de reversível — os docblocks de
AnamneseSchedulerWorker.enabled?/0/PsychologistWorker.enabled?/0 ("Ligar
de volta é X=true e reiniciar o engine") e
docs/reference/configuration.md. Não era reversível em ambiente NENHUM: o
environment: do serviço engine mapeava as TREZE variáveis de
Anamnese/Psicólogo — todas TETOS de custo — e nenhuma das duas de PRODUTO, e
docker compose NÃO repassa o ambiente do host, então ANAMNESE_ENABLED=true
no .env era inerte e runtime.exs caía no default "false" em SILÊNCIO.
Medido no container de dev: [true] [] []. Origem provável: quando o default
virou false, ninguém precisou da variável para DESLIGAR — só quem tenta
LIGAR descobre que o fio não existe, meses depois. As OITO flags booleanas
(System.get_env("X", "true"|"false") == "true") entram nos DOIS composes com
o MESMO default do código — as quatro extras (START_MODEL_SYNC,
START_GATE_RESCUE, os dois GRAPH_*) porque são o mesmo defeito, achado
pelo próprio mecanismo, e excetuá-las exigiria quatro desculpas. Zero mudança
de comportamento: a decisão de produto de manter os dois desligados fica byte
a byte, o que passa a existir é o INTERRUPTOR. O mecanismo é
scripts/ci/flags-do-engine-no-compose.spec.ts, que DERIVA a lista do
runtime.exs (as outras 50 variáveis são tetos com default bom e coisas de
release, legitimamente ausentes) e cobra default igual só no compose de DEV —
o de produção desliga os agentes de fundo de propósito. deploy/k8s/ NÃO
muda, por coerência: lá não existe a camada que quebra
FASE 29 (sessão 1) — instalação de uma linha, só planejamento (ADR 0149-0152, fase-29-instalacao-de-uma-linha.md)
Instalar o Brabo são TRÊS gestos desconexos (compose com cinco segredos à mão,
runner pelo navegador com chmod +x, pnpm bootstrap que só existe num
checkout), e a FASE 28 recusou por escrito o instalador de uma linha enquanto
os artefatos não forem assinados (BRB-005) — por isso a fase assina ANTES de
instalar. Quatro ADRs Proposed: assinatura cosign keyless por DIGEST mais
UM checksums.txt assinado, nunca cinco assinaturas (0149); o instalador
invocado por sh -c "$(curl …)" e NUNCA curl|sh, porque com o pipe o script
É o stdin e o consentimento morre (0150); a base consentida do runner,
reusando dentroDoEscopo e a recusa de laço por SEGMENTO de
espelho-guard.ts em vez de uma quarta cópia da régua (0151); e
backup/restore contra compose (0152). A medição corrigiu CINCO pontos do
recorte original: workspace.verified NÃO existe (o que existe é
workspace_confirm, e ele é UNIDIRECIONAL), o ponto 5 do ADR 0146 versiona
consentir-base.mjs e não um install.sh, docker-compose.prod.yml não
consome GHCR e se declara compose de VALIDAÇÃO, make test-restore é caminho
de Kubernetes com backup só de Postgres, e "um runner por projeto" está
imposto em CINCO lugares independentes — o que tirou o runner por máquina
desta fase, para a FASE 30. Achado próprio: git_local_repos não está em
backup nenhum, e perdê-lo é perder o código dos projetos local. Nasce
docs/reference/brb.md — e o achado é que o registro de BRB não é do
repositório: vive no vault do mantenedor, com 33 itens, e aqui só dois ids
apareciam em prosa. Medindo: BRB-032 e BRB-033 descrevem defeitos que já não
reproduzem, e BRB-004 não é o que o id sugere — pede imagem de terceiro
por DIGEST, não a tag que o #419 fixou. O arquivo daqui guarda a EVIDÊNCIA
(estado medido, arquivo:linha); o vault segue dono da finding. Faixa
RN-524..538 e ADRs 0149-0152, pulando o que está EM VOO: três colisões
observadas no mesmo instante (ADR 0148 ×2, RN-522 ×2, RN-523 ×2), resolvidas
depois pelo critério da DATA — quem alocou primeiro ficou com o número, e as
branches de 08/09 viraram ADR 0153, RN-539 e RN-540
FASE 29 (sessão 2) — a esteira assina o que publica (RN-524, ADR 0149)
O produto assinava nada: cosign/sigstore/attestation não existiam em
.github/, scripts/ ou docker/, e a única procedência era o
--provenance do pacote npm do runner. release.yml publicava as quatro
imagens sem id-token: write; build-runner-binaries.yml subia os cinco
binários com gh release upload e nada mais — enquanto os sha256sum -c que
já existiam verificam binário de terceiro, o padrão que o repositório
sabia aplicar e nunca aplicou a si mesmo. Agora as imagens são assinadas por
DIGEST (nunca por tag, ponteiro móvel) e os binários ganham UM
checksums.txt assinado, num job checksums que espera a matriz — não cinco
assinaturas, porque quem verifica quatro e esquece o quinto não sabe que
esqueceu. O job é if: always() de propósito (fail-fast: false na matriz) e
nomeia o que o manifesto não cobre. Os dois workflows verificam antes de
publicar. Keyless pelo histórico da casa: chave privada seria mais um
segredo de CI, e um PAT já expirou calado (ADR 0139). Procedimento de
verificação no runbook, com as duas flags --certificate-* obrigatórias —
sem elas o cosign aceita assinatura válida de QUALQUER UM. Fecha BRB-005
para imagens e binários; ficam fora, declarados, o code-signing de SO e o
proxy de download
FASE 29 (sessão 3) — o instalador que ainda não instala (RN-526, ADR 0150)
Nasce install.sh na RAIZ, versionado e servido pela Release. A forma é sh -c "$(curl …)" e nunca curl|sh, por motivo MECÂNICO: com o pipe, o
stdin do processo É o download, e todo read lê bytes do próprio script ou
acha EOF — um instalador que não pergunta escolheria sozinho onde criar pasta
na máquina de alguém. Ele VERIFICA A PRÓPRIA ORIGEM (a assinatura do
checksums.txt da RN-524, e o hash dele DENTRO do manifesto verificado), com
o cosign pinado e conferido por sha256sum contra o hash escrito no script;
a DETECÇÃO nomeia o que achou antes de perguntar; sem TTY relata e sai 0; e
nunca apaga pasta de usuário — base de projetos e pasta de espelho ficam
fora de qualquer confirmação, pela régua do espelho (RN-516). Marcador em
$XDG_STATE_HOME/brabo/, nunca em ~/.brabo/, que é POR PROJETO. Não
instala nada nesta versão, e DIZ isso em vez de terminar calado.
--print-plan/--print-state expõem a decisão sem TTY, sem rede e sem efeito
— molde do --print-commands do bootstrap.sh, testado igual. Dois bugs de
bash achados rodando, os dois do mesmo gênero: sob set -e+pipefail, [ cond ] && atribuição que dá falso MATA o script, e um ls que não casa
derruba a substituição inteira — numa função cujo caso normal é "não achei
nada". A RAIZ do repositório não era coberta por docmap nenhum (os globs de
raiz são nomes literais); nasce a regra instalador
FASE 29 (sessão 4) — a instalação sobe de um compose próprio (RN-527, ADR 0150)
Nasce docker/docker-compose.install.yml, GERADO a partir do de produção e
diferente em três pontos: as quatro imagens vêm de variável obrigatória
(${BRABO_API_IMAGE:?…} — com default, faltar a variável subiria metade da
stack com imagem que ninguém escolheu, e o erro viraria comportamento estranho
em vez de recusa), nenhum build: sobrevive, e o serviço broker NÃO
existe porque a imagem dele não é publicada (o bake tem quatro alvos) — com
--source=ghcr, mounted não sobe container, e o compose DIZ isso no
comentário da BROKER_URL. O docker-compose.prod.yml fica intacto: ele se
declara compose de VALIDAÇÃO e é o que o smoke.sh usa construindo local. O
custo dos dois arquivos é declarado e o teto é MECÂNICO —
composes-em-conformidade.spec.ts reprova serviço/volume/porta a mais,
build: sobrevivente e imagem de terceiro afrouxada. Duas coisas do plano
morreram no contato com o disco, por motivos opostos: migrate não é passo
(o compose encadeia api→migrate-api com service_completed_successfully,
e dois lugares mandando migrar seriam duas fontes da mesma verdade) e o
smoke.sh não é reusado (ele CONSTRÓI o que a instalação baixou por digest,
e derruba a stack no fim) — no lugar, o instalador PERGUNTA antes de afirmar,
batendo no /health dos dois
FASE 29 (sessão 6) — migrar exige backup PROVADO (RN-530, ADR 0150/0152)
Instalar por cima de instalação existente não é oferecido — ou migra, ou
para: up sobre volumes de outra versão é estrago que não avisa. A ordem é
backup → PROVAR → perguntar → apagar → instalar → restaurar, e o passo do
meio é o que dá ao instalador o direito de apagar: um backup que ninguém
tentou restaurar é um arquivo, e a hora de descobrir isso não é depois do
down -v. A prova roda test-restore-compose.sh (as MESMAS três validações
do ADR 0152); backup ou prova que falham param a migração sem apagar nada.
O destino é pasta do HOST — o default do compose é o volume backup_local,
que o down -v apagaria JUNTO com o que se quer preservar. O dump do Postgres
NÃO é restaurado sozinho sobre banco populado (é destrutivo, e brabo-restore
nunca toca a origem); os repos git locais sim, porque nasceriam vazios
FASE 29 (sessão 7) — uma base para os dois lados, e o BRB-031 fecha (RN-531, ADR 0150/0151)
O instalador pergunta a base UMA vez e grava nos DOIS lugares:
BRABO_PROJECTS_BASE no .env (servidor, ADR 0141) e o campo base do
runner.json (agente local, RN-529) — o que difere entre mounted e runner
é QUEM executa, não onde o código mora, e duas bases para a mesma pasta seriam
a colisão que o ADR 0141 recusou. Recusa nos DOIS sentidos contra o checkout,
e ~ não é expandido de propósito (o valor vai para arquivo de configuração,
onde ~ é lido literalmente). O binário do runner é conferido contra o
mesmo checksums.txt que o script já verificou para conferir a si mesmo —
baixá-lo de novo seriam duas chances de pegar manifestos diferentes, e a
segunda não seria verificada — e instalado com install -m 0755. BRB-031
fecha por aqui: ele sobreviveu à FASE 28 porque a instalação não vinha de
artefato versionado, e quem chegava ao service install já precisara do
chmod. Release sem o binário da plataforma NÃO interrompe a instalação
FASE 29 (sessões 8-10) — o protocolo, o picker e o E2E (RN-532..534, ADR 0149/0150/0151)
8 (RN-532): nasce o par workspace_create/_result no molde de
exec/exec_result, e a confirmação REUSA o workspace_confirm que já
existe — nenhuma rota de gravação nova, engine continua sem escrever a tabela.
O predicado é PRÓPRIO por uma razão mais forte que a do espelho: ali a
terceira pré-condição seria circular — projeto runner só tem container
running depois de o runner o subir, e o container sobe SOBRE a pasta que
esta mensagem cria. A capacidade workspace é a única cuja declaração depende
do ESTADO (base consentida), não da versão do binário. 9 (RN-533): o
picker volta a ler o disco do usuário por fs_list_dir, e a decisão de NÃO
esconder o modo sem runner é simétrica à RN-513, não contrária — lá a api TEM
sinal e sem base a criação termina em 400; aqui não há sinal de presença (a
RN-521 já declara), e esconder seria decidir "não tem" a partir de "não sei".
Achado: a tela decidia casando a FRASE 'Nenhum runner conectado' que mora
num .ex do engine — virou motivo discriminado. 10 (RN-534): o
install.sh entra no checksums.txt assinado (sem isso a verificação que ele
faz de SI MESMO era letra morta) e é exercitado em máquina limpa, com TTY
simulado por script -qec e nunca por pipe — o pipe é o que mata o
consentimento. NÃO roda em PR, pelo mesmo motivo da assinatura
FASE 29 (sessão 8) — a base do agente local ganha um consumidor (RN-532, ADR 0151 pontos 3–6)
A RN-529 construiu a BASE e declarou por escrito que nada a consumia. Entra o
par workspace_create/workspace_create_result, no molde de PEDIDO COM
RESPOSTA de exec/exec_result — e NUNCA no de workspace_confirm, que é
UNIDIRECIONAL ({:noreply, socket}, não empurra nada de volta), precisão que
a fase já tinha registrado junto com "workspace.verified NÃO existe". Só o
SEGMENTO relativo atravessa a rede (invariante do ADR 0130/0144: quem tem a
raiz é quem executa), e segmento ABSOLUTO é recusado por léxico. A confirmação
REUSA o workspace_confirm que já existia, e é o que economiza uma frente
inteira: nenhuma rota nova de gravação nasce, o engine continua não
escrevendo a tabela, e o único caminho que carimba workspace_verified_at
continua sendo um só — com a ORDEM como mecanismo (confirm ANTES do resultado,
porque os dois chegam ao mesmo processo de canal em ordem e o handler do
confirm é síncrono). A capacidade workspace entra no vocabulário NA SESSÃO
EM QUE O CÓDIGO ENTRA, dos dois lados — a RN-514 fez a aposta oposta com
espelho e ela só se pagou porque alguém a cobrou —, e ela é a ÚNICA das
quatro cuja declaração depende do ESTADO daquela execução: o runner só a
declara com base consentida, e é por essa linha, e por mais nenhuma, que o
servidor SABE que existe base (o engine não lê o disco do usuário). NINGUÉM a
exige no join, de propósito: exigi-la em runner seria breaking/ e MAJOR
por uma função que aquele projeto talvez nunca use. Predicado PRÓPRIO
(Engine.Runners.PastaDoProjeto), DUAS pré-condições, RunnerReadiness byte
a byte — e aqui a terceira seria pior que desnecessária, seria CIRCULAR: um
projeto runner só chega a ter container running depois de o runner o
subir, e o container sobe sobre a pasta que esta mensagem existe para criar.
Ele também não pergunta workspace_verified_at (aqui é o RESULTADO) nem o
modo. As DUAS pré-condições são respondidas em lugares diferentes porque "base
consentida" é propriedade da CONEXÃO (socket.assigns, nunca tabela): o canal
recusa ANTES de empurrar, com motivo: "sem-base" pelo mesmo ref, no
formato que o módulo espera — nunca timeout. Cinco motivos nomeados que não
colapsam, idempotência com desfecho PRÓPRIO (ja-era-repositorio é sucesso),
e estado.dir NÃO muda. Não é proposed_action e nenhum teto ganha exceção
FASE 29 (sessão 5) — o backup passa a cobrir o que perder dói (ADR 0152, RN-528)
O backup cobria UM banco e rodava NUM lugar: make test-restore é caminho de
Kubernetes (exige kubectl, cluster e o CronJob) e docker-compose.prod.yml
não tinha serviço backup NENHUM — migrar uma instalação por compose não
tinha caminho provado. A decisão começa RECUSANDO o movimento fácil ("fazer
backup de todos os volumes") e CLASSIFICANDO os sete: neo4j_data e
project_workspaces são derivados, ollama_data é re-obtenível,
brabo_projects_base é do usuário — copiá-los dá impressão de cobertura sem
acrescentar recuperação. Fonte de verdade são DOIS, e o achado é o segundo:
git_local_repos não estava em backup nenhum, e um projeto com provider
local guarda o bare ali — não é reconstruível do Postgres (o event log
guarda a NARRATIVA, não os objetos do git), então perder o volume é perder o
código dos agentes. A prosa do runbook afirmava o contrário ("the real
repositories live in GitHub/GitLab") e é ela que explica os anos sem
cobertura: vale para github/gitlab, e para local não há upstream. A
garantia do arquivo é DECLARADA e menor que "snapshot" — consistência por
REFERÊNCIA (git escreve objetos antes de mover a ref, e troca a ref por rename
atômico), sem instante global e sem git fsck (a imagem não tem git, de
propósito); gc concorrente vira FALHA e não arquivo parcial, com o status do
tar capturado num ARQUIVO porque em POSIX sh o status de um cano é o do
último comando. As cinco variáveis de S3 deixam de ser obrigatórias e viram a
configuração de UM destino (BACKUP_DIR escolhe disco; ausente, S3 — a
inferência é o que mantém o CronJob INTACTO). make test-restore-compose
dispara backup REAL e chama o MESMO brabo-restore com as MESMAS três
validações: muda o INVÓLUCRO, nunca o julgamento, e
deploy/k8s/test-restore.sh fica INTACTO (unificar faria o de compose
depender de kubectl); restore.sh segue sem tocar a database de ORIGEM.
Restaurar os repos é comando IRMÃO (brabo-restore-git, verifica por default,
escreve só com --restaurar) e não fase do outro — dois artefatos, dois
julgamentos, e juntá-los faria falha de git reprovar a validação do event log.
Achado RODANDO, que a leitura não daria: o volume é compartilhado por três
imagens com uids diferentes e carrega o dono de quem o montou primeiro (medido
1000:1000, modo 0755), enquanto a imagem roda como uid 70 — a extração
morria no meio deixando o volume pela metade; agora recusa ANTES do tar
nomeando --user 0:0, e o extraído como root volta ao dono do DIRETÓRIO,
senão o volume voltaria íntegro e INÚTIL (api e engine, non-root, sem
conseguir escrever no que se acabou de restaurar). Neo4j recebe REPROJEÇÃO e
não backup — não construída aqui (BRB-018): instalação migrada nasce com o
grafo VAZIO, degradação nomeada e não perda de dado (o RAG vive no pgvector,
dentro do dump)
FASE 30 — o agente local da máquina (ADRs 0154/0155, RN-543..552, fecha na sessão 8 com a RN-549)
A FASE 29 entregou instalar numa linha e PAROU em dois pontos que o próprio
produto já declarava por escrito: o agente local continuava pareado por
PROJETO (quem quisesse usar o binário instalado voltava ao navegador para colar
um comando no terminal), e ninguém conseguia entrar — o .env gerado não
tem variável de e-mail, MAIL_TRANSPORT cai em log e o registro exige
verificar e-mail, então a saída era pescar o link em docker compose logs api.
Os dois se resolviam no mesmo lugar, e é isso que fez uma fase só. O achado que
definiu o TAMANHO dela veio de remedir com arquivo:linha a afirmação da FASE
29 de que "um runner por projeto está imposto em cinco lugares": três dos
cinco não precisavam mudar, porque tópico, socket id e ticket descrevem uma
CONEXÃO, e um processo que abra N conexões — uma por projeto — os satisfaz byte
a byte, preservando a recusa de segundo runner no mesmo projeto, que é garantia
real e não acidente. Nada no engine mudou na fase inteira, e foi o que
manteve RunnerReadiness, o espelho e workspace_create intactos. O que sobrou
foi o que é do PROCESSO: a credencial (runner_device_keys.project_id vira
nullable, RN-543 — e a listagem passa a devolver as DUAS espécies marcadas,
senão uma chave de máquina seria invisível e permanente, o defeito que a RN-519
fechou renascido) e a unit (RN-545, service install --machine, uma por
máquina, CONVIVENDO com as por projeto). Duas decisões contrariaram a letra dos
ADRs e as duas foram MEDIDAS: o discriminador da unit é a flag --machine e não
a ausência de --project (resolverProjeto tem duas fontes, e o caminho normal
é rodar install sem flag de dentro da pasta que o navegador configurou — ao pé
da letra, o ADR converteria em silêncio a instalação de quem já usa o produto), e
install RECUSA quando a outra espécie já está instalada, sem --force, porque
as duas juntas seriam dois processos disputando o mesmo projeto. A sessão 6
tocaria três workspaces de uma vez e foi DIVIDIDA em três, com as duas peças
disjuntas saindo antes e o install.sh depois delas, como orquestrador e nada
mais. Dessa divisão saiu a revisão mais interessante da fase: com ZERO conexões
o agente deixou de SAIR e passou a ESPERAR, reconsultando a 15s/30s/60s
(RN-550) — o argumento da RN-544 contra repesquisa continua inteiro onde ele
vale (lista que volta MENOR é ambígua, e derrubar conexão VIVA por ambiguidade
troca estado certo por palpite), e com zero conexões não há nada a derrubar; o
que caiu foi o argumento do exit 0 ("um serviço ativo que não faz nada"),
porque o processo passou a fazer algo e a DIZER que faz. Medido: Restart=on-abnormal
não olha código de saída, então nenhuma unit mudou e o efeito é a de máquina
ficar active (running) de verdade. A outra peça (RN-551) fecha o "ninguém CRIA
chave de máquina" pelo lado do agente, e a decisão ali é o CORTE: o CLI não
fala com a api, porque quem registra é o instalador com o BRABO_SERVICE_TOKEN
— cada lado guarda um segredo e nenhum vê o do outro. Disso decorre o desenho de
dois passos, e dele decorre o invariante: o kid É o id do registro e só existe
depois dele, então o create grava um .parcial que o leitor ignora e o
finish grava o nome de verdade — o arquivo que o runner lê nunca existe sem
kid, o defeito da RN-475 tornado impossível por construção. A metade de api
(RN-552) declarou o custo em vez de escondê-lo: a credencial é o service token,
e um token vazado passa a poder FABRICAR credencial duradoura — por isso três
contenções vivem na ROTA e não no texto (não há userId no corpo; registrar
SUBSTITUI as chaves de máquina ativas do dono na mesma transação, então máquina
reinstalada passa e mil chaves são impossíveis por cláusula, sem número que
envelheça; e JWK com d é recusada por nome). A sessão 6 (RN-547) encadeou os
cinco comandos e fixou três regras: elo do meio que falha não desfaz nada, vira
linha nomeada num bloco final e o script SEMPRE sai 0 (desfazer exigiria apagar
conta e revogar chave, e não há rota para isso nem deveria haver uma que o
instalador chame sozinho); idempotência é o CÓDIGO HTTP, e 409 é desfecho
ESPERADO e não falha, com 000 tratado à parte para "a api não subiu" não ser
lido como "a api recusou"; e a senha viaja pelo STDIN do curl, nunca por
argv, porque /proc/<pid>/cmdline é legível por qualquer usuário da máquina.
Achado dessa sessão: o --help MENTIA havia meses ("ESTA VERSÃO NÃO INSTALA
NADA") porque ele imprime o cabeçalho, que é comentário, e a regra que proíbe
falar de "sessões de fase" só lia linhas de código. A sessão 7 (RN-548) deu
consumidor à marca de espécie, e a decisão foi de HONESTIDADE: com chave e sem
conexão viva — o caso mais comum — a tela diz as duas coisas que sabe e nomeia
as duas que não sabe (chave registrada não é agente rodando, a lista é da sua
CONTA e não deste navegador), tom accent e nunca success. A sessão 8
(RN-549) é a única que podia provar o conjunto, e ela ESTENDEU o workflow que a
FASE 29 já tinha em vez de criar um segundo. A decisão central dela é o SINAL:
perguntarEstado mapeia activating para rodando — com razão —, então
systemctl is-active e service status --machine dariam verde a um processo
que vai morrer em dois segundos; o sinal escolhido é a LINHA que o agente
escreve ao entrar na espera, que só existe depois de ele ter lido a chave,
tirado ticket, falado com a api e recebido lista VAZIA, e que é um FATO GRAVADO
e não uma amostragem de estado. O desfecho se prova pela API e não pelo
navegador, porque o sujeito da afirmação é o AGENTE e o que ele observa é
GET /runner/projects — um navegador criaria o projeto pela mesma rota com uma
camada a mais que falha por motivos alheios, e a asserção continuaria sendo a
linha do journal. E o E2E segue FORA de pull_request, com a consequência
declarada em vez de escondida (o PR que o escreveu não o executou): abrir
essa porta é dar ao instalador um jeito de pular a verificação de origem, que é
o que o ADR 0150 recusa — daí a metade que roda em PR ser um TESTE
(scripts/dev/install-e2e.spec.ts), guardando as duas formas de o workflow
apodrecer calado, o gate afrouxado e a frase do install.sh reescrita (que faz
as asserções não falharem, e sim SUMIREM). Dois defeitos apareceram, os dois
invisíveis porque o workflow nunca rodou: as respostas do TTY começavam por
nao-migrar, que numa máquina limpa cai na pergunta do MARCADOR e fazia o
script sair 0 dizendo "Nada foi gravado"; e o ACHADO que ficou como achado — o
install.sh publicado não sobe nada sozinho numa máquina limpa, porque
docker/docker-compose.install.yml (com ./postgres/init.sql junto) não é
asset da Release, não entra no checksums.txt assinado e ele não o baixa em
lugar nenhum, então quem segue o curl … install.sh do runbook morre em "no
such file or directory" DEPOIS de já ter verificado assinatura e gravado o
.env. Consertar é decidir entre asset assinado e clone, e é entrega própria.
O que a fase deixou DECLARADO e não feito: a revogação continua alcançando
{projeto, usuário} e nunca {chave}, e agora custa mais (derruba o agente em
TODOS os projetos daquela máquina); não há tela onde listar e revogar chave de
dispositivo, e a fase acrescentou uma espécie a listar sem construir a tela — a
lacuna ficou MAIOR, de propósito; um processo por máquina é ponto único de
falha, preço do desenho; e install --machine continua sem saber se a chave
daquela pasta é mesmo de máquina, porque em disco as duas espécies são o mesmo
arquivo e quem sabe é o servidor
O golden-set do QA volta a medir e roda agendado, semanal (AT-076/AT-149, ADR 0168)
Duas entregas, uma decisão humana entre elas. A AT-067 tinha medido que o
relógio cabia num ubuntu-latest sem GPU e que o instrumento não media: desde a
RN-502 o npm test de um projeto container sem container running é
recusado, e o seed nunca subia um — 0/6 por construção. O mantenedor escolheu o
"caminho 1" e a AT-076 o entregou: o seed sobe o container de cada caso pelo
caminho de PRODUÇÃO (module_map → roteamento → container_start proposto pela
Infra e aprovado pelo dono → ExecuteContainerStartUseCase → broker → running
pela máquina de estados), com node:24.11.1-bookworm-slim preso por digest, a
RN-502 intacta, e container que não sobe virando erro NOMEADO que reprova o
ExUnit (antes o {:skip, _} do corpo passava verde calado). Duas rodadas em CI
deram 3/6 e 3/6 contra o piso de 1/6, com casos diferentes, em 52 e 86 min de
job. A AT-149 fechou a cadência: SEMANAL (sábado 01:17 UTC, mais
workflow_dispatch, nunca pull_request), em golden-set-qa.yml, no molde do
golden-set-rag.yml. Semanal e não noturno porque a rodada custa até 86 min e
VARIA — o mesmo total por casos diferentes —, e uma por semana basta para o piso
acusar uma queda. timeout-minutes: 150 pelo pior caso medido. O veredito é o
do próprio teste; o workflow acrescenta só duas guardas de INSTRUMENTO (a linha
de placar tem de existir, e o event log não pode ter recusa da RN-502) e, em
falha, imprime os logs como o do RAG — sem issue. A imagem a puxar antes do seed
é LIDA da constante do seed, nunca de um segundo literal, porque o docker run
do broker roda sob o teto de controle de 30 s (RN-604). A rodada de prova, com
gatilho temporário de push removido antes do PR, foi a 36327281774: 3/6 de
novo, zero recusas da RN-502, 10 min de mix golden_set.qa e 13 min 41 s de
job. Declarado: o broker do CI não tem as cinco camadas do compose, o heartbeat
fecha as sessões do seed enquanto o QA ainda trabalha nelas (sem efeito
visível), e PR que mexe no workflow não o executa.
O Infra Lead conversa pelo composer (AT-141, ADR 0175, RN-617)
A RN-584 (AT-098) tirou o destinatário padrão do chat e deixou a pergunta de
produto por escrito: o Infra Lead conversa? O mantenedor disse sim em
2026-09-27, com a condição que a própria RN-584 tinha posto — o turno primeiro.
O turno do Infra Lead rodava INTEIRO dentro do handle_call/handle_cast: sem
aceite, sem "Parar", fora do TurnoOrfao, com o 14º passo do laço terminando
calado e um teto de 180 s menor que os 225 s do propose_action de container
(RN-605). O kickoff, sendo cast, não prendia clique, mas prendia o processo —
8,4 min medidos na AT-089 —, e uma mensagem nesse meio tempo esperaria na fila.
A etapa 1 pôs os três turnos (kickoff, correção de gate, mensagem) no
TurnoAssincrono, com a consolidação do Workflows dentro da Task; a correção de
gate que chega com turno em curso ganhou uma fila, porque o TurnoAssincrono
sem from a descartaria. A etapa 2 deu ao infra a cláusula de message/2
(saiu a recusa agente_sem_conversa), o via_for do "Parar" e o lugar em
AGENTES_DE_CHAT; o aceite do handoff dele ficou no card próprio da RN-499, e
o card do fio o exclui por nome. SOLO_CONVERSATIONAL_AGENTS não mudou: o Infra
Lead é lead de área. Declarado: ele não ganhou perguntas estruturadas nem
leitura de backlog.
O git credenciado roda no host do runner, o código no container (AT-116, ADR 0193, RN-676)
2026-10-01, rodada 36. A RN-558 tinha fechado a metade do SILÊNCIO — o git fetch autenticado em modo runner, com o container de pé, deixou de rodar com
as variáveis vazias e passou a ser recusado com a marca
credencial-nao-atravessa-o-container — e declarado a outra metade como ADR
pendente, porque toda opção conhecida mexia na porta de contenção do ADR 0130.
A decisão do dono (01/10) escolheu a que não mexe: operação de git credenciada
no HOST, código no container. Medido antes: o único exec do engine com env
era o fetch de RunnerGit.fetch!/3; não havia push nem clone credenciado
por exec (o clone da criação de pasta já rodava no host, RN-532); e a pasta é
a mesma dos dois lados (estado.dir montada em /work). O discriminador
escolhido foi uma MARCA explícita do engine (gitCredenciado: true, posta só
por RunnerRouter.exec_git_credenciado/5), e não o env que já existia —
com o env como chave, qualquer comando com env escaparia do container. A
recusa da RN-558 sobreviveu encolhida, para env sem a marca, e na prática
passou a significar "runner anterior ao ADR 0193". A prova é a AT-111, dos dois
lados: no runner, um git credential fill e um git fetch origin reais com o
helper do GitAuth sucedem no host com container ativo; no engine, a corrente
pelo TerminalChannel real termina em {:ok, _}. Os ExUnit não rodaram no
ambiente da entrega (repo.hex.pm 403) e ficaram para o CI.
A consultiva sem agente pede um agente (AT-254, RN-682)
2026-10-01, rodada 36. No uso real de 29/09 uma sessão consultiva sem agente
respondeu "não tenho acesso a conversas anteriores": a mensagem sem
destinatário ia ao SSE de POST .../chat, que manda ao modelo vinculado só o
texto atual — sem histórico, sem prompt de sistema — e grava a resposta com o
nome do modelo como ator. A decisão do dono (01/10) foi pedir um agente em vez
de dar histórico ao chat livre. Medido antes: a tela era o único cliente da
rota (o curl impresso pelo seed é dica de desenvolvimento, numa sessão
criativa), e o caso de uso SendChatMessageUseCase tinha um segundo consumidor
legítimo, os smokes de provider, que o usam como instrumento de ponta a ponta
numa sessão sem agente. Por isso a recusa da api mora na ROTA, numa guarda
própria, e o caso de uso ficou intacto. Na tela, a linha do destinatário passou
a carregar o próprio seletor do handoff manual quando não há agente — o mesmo
gesto de sempre, agora no lugar onde ele é a única forma de a mensagem ter
destino —, e o Criativo ficou fora da lista porque a consultiva promete que ele
não entra.
A revogação mira a chave, e não o par {projeto, usuário} (AT-013, ADR 0201, RN-685)
2026-10-01, rodada 36. A RN-520 tinha feito a revogação alcançar a conexão
viva, com o alvo {projeto, usuário} porque a identidade da credencial morria
no PatAuthGuard; o ADR 0154 pôs "revogação por chave" entre o que não
fazia, e o PR #534 deu à chave de máquina o mesmo par aplicado projeto a
projeto. A AT-013 ficou bloqueada por recorte e por decisão até o dono
escolher (01/10): por CHAVE, com ADR, mexendo no engine. Medido antes: o
ticket nascia de {userId, kind}, o socket guardava só projeto/usuário/kind,
e o canal comparava o usuário — o runner do mesmo usuário com PAT ou outra
chave caía junto; a conexão da chave de máquina num projeto fora da lista da
api ficava de pé; o ticket emitido antes da revogação entrava depois dela; e
revogar PAT não derrubava nada. Entregou-se: duas colunas nuláveis no ticket
(migration Ecto, a tabela é do engine — o diário do Drizzle ficou na 0068),
o guard anotando a credencial depois de autorizar, o id do socket ganhando
espécie e id da credencial, e uma rota runner/disconnect-credential que anula
os tickets pendentes e pergunta a todo runner do cluster se nasceu dela. A
proibição da AT-013 (não deixar de derrubar o que caía) virou duas peças de
transição: a conexão legada cai pelo par nos projetos da linha, e um engine
sem a rota faz a api voltar ao par. O par ficou como alvo da remoção de
membro. A tela trocou só o texto da confirmação (RN-561). O runner não mudou.
Os ExUnit não rodaram no ambiente da entrega (repo.hex.pm 403) e ficaram
para o CI.