diff --git a/docs/handoff/desenvolvimento/HANDOFF-BUILDTOVALUE.md b/docs/handoff/desenvolvimento/HANDOFF-BUILDTOVALUE.md new file mode 100644 index 0000000..e35a50e --- /dev/null +++ b/docs/handoff/desenvolvimento/HANDOFF-BUILDTOVALUE.md @@ -0,0 +1,696 @@ +# Handoff de Desenvolvimento — BuildToValue (evolução do `mix_btv_code`) + +> **Objetivo deste documento:** dar a um desenvolvedor tudo que ele precisa para implementar, +> **sem abrir nenhum outro repositório**. Todo caminho de arquivo, contrato, assinatura, trecho +> de código de origem e passo de verificação está inlinado aqui. Onde citamos um repo de origem +> (btvChatCorp, squadIA, etc.), o **padrão relevante já está copiado no texto** — o repo é só a +> proveniência, não uma dependência de leitura. +> +> **Base de análise:** leitura profunda de 11 repositórios irmãos (btvChatCorp, squadIA, +> SquadIAds, buildtovalue-governance, silent-decisions-proof, BuildToValue, BuildToValueV7, +> BuildToValueIDE, BuildToValuePython, buildtovalue-factory) cruzada com o estado atual do +> `mix_btv_code`. A síntese executiva está em `docs/handoff/desenvolvimento/` (este arquivo); +> o roadmap resumido também. +> +> **Branch de trabalho:** `claude/buildtovalue-repo-analysis-ek8eje`. +> **Convenções do repo:** código/comentários em português, identificadores em inglês; testes +> junto do módulo (Rust `#[cfg(test)]`, Python `tests/`); contrato tem fonte única em `schemas/`. + +--- + +## 0. Contexto e norte do produto + +O `mix_btv_code` (codinome interno "Forge") **passa a se chamar BuildToValue** — o nome oficial +que ele herda do produto Python original. O produto migra de *agente de código para +desenvolvedores* para uma **plataforma onde profissionais de qualquer área montam squads de +agentes de IA** que rodam uma **esteira** (linha de produção): **briefing → produzir → revisar → +exportar** entregas reais (DOCX, XLSX, MusicXML, MIDI, PDF, SVG…). + +Cinco princípios de experiência (dos documentos de design anexados ao pedido): +1. **A esteira é a interface.** Linha de produção horizontal; nada de logs/tokens/grafos na visão + padrão. +2. **Papéis humanos, não agentes.** Pauteiro, Redator, Revisor, Copista — a equipe espelha + equipes reais da profissão. +3. **O humano é um gate, não um espectador** — e, com este handoff, também um **membro** da squad. +4. **A entrega é o produto.** Artefatos exportáveis com versão e procedência. +5. **Complexidade tem endereço próprio.** Telemetria/ledger/providers/permissões vivem no perfil + Admin. + +A **filosofia herdada** (tem que sobreviver ao rename): *"Squad sobre Solo → Sinergia → +Velocidade → **Valor**"*, *"Valor sobre Complexidade"*. A esteira **é** essa cadeia de valor. + +**O pedido que originou este handoff:** além de incorporar as melhores ideias dos repos, colocar +um **chat junto ao squad onde o usuário é um membro ativo** — "não apenas o aprovador, mas o outro +item da squad". Essa é a **Fase 1** e o foco principal. + +--- + +## 1. Arquitetura atual do `mix_btv_code` (o que já existe) + +Você vai **estender**, não reconstruir. Entenda este encadeamento primeiro. + +### 1.1 Fronteira Rust × Python (ADR 0001) +- **Rust** (`crates/`): tudo que toca disco/rede/processo/segredo ou roda a cada keystroke — CLI/ + TUI, sessões, gateway LLM (API keys **só** aqui), ferramentas, permissões, `/verify`, storage, + ledger, servidor web (`forge-server`, axum). +- **Python** (`python/packages/`): tudo que decide o próximo passo por raciocínio de agente — + `forge-squad` (orquestrador multi-agente), `forge-promptforge`, `forge-review`, `forge-eval`. + **Python nunca chama provedor LLM direto** — sempre via `CoreService.Generate` (gRPC). +- **Integração:** gRPC bidirecional sobre Unix Domain Socket (`tonic`/`prost` × `grpcio`). + +### 1.2 O caminho do squad ao vivo (é AQUI que a Fase 1 mexe) +Fluxo ponta-a-ponta de uma execução de squad pelo navegador, com os arquivos reais: + +``` +Navegador (web/) Rust (crates/forge-cli/src/squad_agent.rs) Python (forge-squad) +───────────────── ─────────────────────────────────────────── ──────────────────── +POST /api/squad/run ────────► run_squad_handler + {task} → SquadHub::new_task() → "sq1" + → tokio::spawn(run_squad_task(...)) + │ gRPC ExecuteTask(SquadTask) ───────────► server.py::ExecuteTask +◄──────────── 202 {task_id} │ → UnifiedOrchestrator + │ .execute_complex_task( +GET /api/squad/{id}/events │ task, event_sink=sink) + (EventSource / SSE) ────────► squad_sse_handler → emite dict events: +◄════ stream SquadEvent ═══════ ← SquadHub::publish(task_id, ev) ◄══ SquadEvent ══ {"kind":"proposal"|...} + │ (_to_squad_event mapeia dict→proto) + │ + (quando consenso fraco / permissão) orchestrator emite +POST /api/squad/{id}/hitl ───► resolve_hitl_handler {"kind":"hitl", ...} e + {allow} → SquadHub::resolve_hitl(id, allow) o backend Rust bloqueia + (destrava o oneshot pendente) em request_permission → + SquadHub::request_hitl +``` + +**Arquivos-chave (leia antes de codar):** +- `crates/forge-cli/src/squad_agent.rs` — `SquadHub`, handlers HTTP, `router()`, `run_squad_task`, + `WebSquadCoreBackend`. É o coração da Fase 1. +- `schemas/proto/squad.proto` — contrato `SquadService.ExecuteTask(SquadTask) → stream SquadEvent`. +- `schemas/proto/core.proto` — `CoreService` (o back-channel Python→Rust): `Generate`, `RunTool`, + `AppendLedger`, `Recall`/`Remember`, `RequestPermission`. +- `python/packages/forge-squad/src/forge_squad/server.py` — implementa `ExecuteTask`; mapeia os + dicts do orquestrador para `SquadEvent` (`_to_squad_event`). +- `python/packages/forge-squad/src/forge_squad/orchestrator.py` — `UnifiedOrchestrator. + execute_complex_task(task, event_sink)`; é onde os agentes propõem e o consenso decide. +- `web/src/api/squad.ts` — cliente do frontend (`runSquad`, `resolveHitl`, `connectSquadEvents`). +- `web/src/components/screens/user/Squad.tsx` — a tela do squad ao vivo. + +### 1.3 Contratos `SquadEvent` e `SquadTask` (estado atual — `schemas/proto/squad.proto`) +```proto +syntax = "proto3"; +package forge.squad.v1; + +service SquadService { + rpc ExecuteTask(SquadTask) returns (stream SquadEvent); + rpc Health(HealthRequest) returns (HealthResponse); +} + +message SquadTask { + string task_id = 1; + string description = 2; + string decision_type = 3; // ex.: "architecture" + uint32 max_autonomy_level = 4; // IGNORADO ponta-a-ponta hoje (ADR 0021) + string verification_evidence_json = 5; // verification-evidence.v1; fail-closed se ausente +} + +message SquadEvent { + string task_id = 1; + string ts = 2; + oneof payload { + Proposal proposal = 3; + Consensus consensus = 4; + Handoff handoff = 5; + HitlEscalation hitl = 6; + StepResult step = 7; + string error = 8; + } +} + +message Proposal { string agent = 1; double confidence = 2; string content_json = 3; } +message Consensus { string decision_maker = 1; double strength = 2; string decision_json = 3; bool requires_human = 4; } +message Handoff { enum Phase { PHASE_UNSPECIFIED=0; START=1; ACK=2; COMPLETE=3; ERROR=4; } Phase phase=1; string from_agent=2; string to_agent=3; string contract=4; string payload_digest=5; } +message HitlEscalation { string reason = 1; double confidence = 2; } +message StepResult { string step_id = 1; bool success = 2; string summary = 3; } +``` +Regras de contrato (obrigatórias): **protos evoluem só aditivamente**; mudança breaking = novo +arquivo `.v2` + ADR novo. Novos campos usam **os próximos números de tag livres** (no `oneof +payload`, o próximo é **9**). + +### 1.4 O `SquadHub` (Rust) — mecanismo que você vai reusar como molde +Assinaturas reais atuais em `crates/forge-cli/src/squad_agent.rs`: +```rust +pub struct SquadHub { /* tasks: Arc>>, hitl_timeout, next_task_seq */ } + +impl SquadHub { + pub fn new(hitl_timeout: Duration) -> Self; + fn new_task(&self) -> String; // gera "sq{seq}", cria estado + pub fn publish(&self, task_id: &str, event: SquadEvent); // append no log + broadcast + fn finish_task(&self, task_id: &str); // dropa o Sender → SSE fecha + fn subscribe(&self, task_id: &str) -> (Vec, Option>); + async fn request_hitl(&self, task_id: &str) -> bool; // BLOQUEIA no oneshot até resposta/timeout (fail-closed: nega) + fn resolve_hitl(&self, task_id: &str, allow: bool) -> Result<(), ()>; // destrava o oneshot pendente +} +``` +`SquadTaskState { log: Vec, tx: Option>, pending: +Option }`. O `PendingHitl { responder: oneshot::Sender }` é o padrão exato de +"back-channel bloqueante do Rust esperando uma ação HTTP do usuário" — a Fase 1 replica ISSO para +mensagens de chat. + +### 1.5 Como o orquestrador Python emite eventos hoje (`orchestrator.py`) +```python +async def execute_complex_task(self, task, event_sink=None): + self._event_sink = event_sink + plan = await self.planner.create_adaptive_plan(task) + proposals = await self._get_squad_proposals(plan) # architect/developer/auditor/... → emite "proposal" + consensus = self.consensus.reach_consensus(proposals, "architecture") + await self._emit({"kind":"consensus", "decision_maker":..., "strength":..., "requires_human":...}) + if consensus.requires_human: + await self._emit({"kind":"hitl", "reason":"weak_consensus", "confidence":...}) + approval = await self.autonomy.execute_with_autonomy(...) # chama RequestPermission (gRPC) → bloqueia no Rust + if not approval.get("executed", False): return {...} # reprovado → aborta + execution_results = await self._execute_plan_steps(plan, task) # emite "step" + ... + +async def _emit(self, event): + if self._event_sink is not None: await self._event_sink(event) +``` +E `server.py::_to_squad_event` mapeia `{"kind": ...}` → `SquadEvent`. **Ponto crítico:** o +orquestrador é um **pipeline determinístico** (plano → propostas → consenso → passos). Não há hoje +um ponto de "esperar o turno do usuário". Fazer o usuário virar membro **de verdade** = adicionar +pontos de consulta a esse pipeline (Fase 1, §3). + +--- + +## 2. Visão geral do que incorporar (mapa dos repos → alvo) + +| Origem | O que porta | Onde entra | Prioridade | +|---|---|---|---| +| **btvChatCorp** | Transporte chat SSE-sobre-`fetch` (auth), enum tipado de payload, montador de system-prompt empilhado, `messages.sources/feedback` on-row, fila de curadoria = ledger de review, worker `SKIP LOCKED`, robustez de RAG (degrada-nunca-falha), webhooks/api-keys/white-label/audit | Fase 1 (chat) + Fase 2 (export/worker) + Fase 3 (admin) | **Máxima** | +| **BuildToValue/V7** | Filosofia da cadeia de valor; três gates (Spec-First/Plan-First/Harden); **biblioteca de personas como conteúdo** (`persona.v1`); **DSL de plano** (`plan.v1`); roteamento por problema; placar "what-matters" | Fase 2 (persona/plano) + Fase 4 (método) | **Alta** | +| **BuildToValueIDE** | **Editor Monaco de entrega** (save gateado + ledger-on-edit); **Squad Composer (ReactFlow)**; arquitetura WS de run ao vivo; **validador Prompt Integrity**; X-Trace-ID; CLI headless | Fase 2 (editor) + Fase 3 (prompt integrity) | **Alta** | +| **squadIA** | Roteamento por **confiança de 4 fatores**; espinha operacional (dep-graph, health, logging, validação); **Decisão→ADR**; histerese; governança-por-decorator | Fase 4 (endurecimento) | **Média** | +| **SquadIAds** | Subsistema de **export** (BaseExporter + manifesto); modelo de dados plano/artefato/validação; validação graduada; pipeline de hardening. **NÃO** portar o loop L1-L5 | Fase 2 (export/validação) | **Média** | +| **buildtovalue-governance** | Scoring de **impacto regulatório**; resultado de **4 estados** + piso-crítico-irredutível; **kill-switch** Prioridade-Zero; relatório de compliance; base-global+overlay-por-profissão | Fase 3 (governança) | **Média** | +| **silent-decisions-proof** | **Produzir≠Revisar≠Aprovar** (capacidades disjuntas); **HMAC por entrada** no ledger; versionamento+expiração de template; tokens lineares de evidência (Rust real, P1/P2) | Fase 3 (confiança) | **Média** | + +### O que NÃO portar (descopes explícitos — não deixe vazar) +- **Loop de auto-promoção/rebaixamento L1-L5** do SquadIAds e os bridges de integração — não + governam nada nem no próprio repo (a esteira nunca lê o nível; bridges são stubs de dados + fixos; métricas placeholder). Isso **reforça** o ADR 0021. Mantenha autonomia como rótulo + descritivo consultável, não como gate que se auto-ajusta. +- **Roteamento vencedor-leva-tudo** do squadIA (substituiria o consenso ponderado). Use a + matemática de confiança **dentro** do consenso (pesar votos), não no lugar dele. +- **Classificador keyword/regex** e os números "100% / €1.28B / 0.21ms" do buildtovalue-governance + (constantes de marketing, eval circular). Porte o mecanismo de scoring, nunca os números. +- **Core executável** do BuildToValue original (recuperação de artefato por regex só-Ollama, + ledgers vazios, scripts faltando). Herde método e conteúdo, não código. +- **Circuitos ZK/Noir** do silent-decisions (PoC meio-stub) — item de roadmap distante. + +--- + +## 3. FASE 1 — Usuário como membro ativo da squad (feature de destaque) + +**Meta:** um chat ao vivo ao lado da esteira onde (a) as mensagens agente-a-agente aparecem como +conversa, e (b) o usuário posta mensagens que a squad **consome como contribuição de um membro**, +não só aprova/reprova. Mantemos os gates HITL (decisão "membro E gate"). + +Entregamos em duas sub-fases: **1a (MVP não-bloqueante)** e **1b (membro pleno com pontos de +consulta)**. Ambas usam a mesma fundação de contrato. + +### 3.1 Contrato — adicionar mensagens de chat ao `SquadEvent` (aditivo) +Em `schemas/proto/squad.proto`, adicione ao `oneof payload` (próxima tag livre = **9**) e uma +mensagem nova: +```proto +message SquadEvent { + string task_id = 1; + string ts = 2; + oneof payload { + Proposal proposal = 3; + Consensus consensus = 4; + Handoff handoff = 5; + HitlEscalation hitl = 6; + StepResult step = 7; + string error = 8; + ChatMessage chat = 9; // NOVO — mensagem de conversa (agente OU usuário) + } +} + +// Mensagem de conversa renderizada no chat ao vivo. `author_role` distingue +// um membro-agente do membro-humano; `author` é o nome de exibição (papel). +message ChatMessage { + string author = 1; // ex.: "Arquiteto", "Você", "Revisor" + AuthorRole author_role = 2; // AGENT | HUMAN | SYSTEM + string text = 3; // conteúdo em linguagem natural + string in_reply_to = 4; // opcional: id de mensagem/etapa a que responde +} + +enum AuthorRole { + AUTHOR_ROLE_UNSPECIFIED = 0; + AGENT = 1; + HUMAN = 2; + SYSTEM = 3; +} +``` +Depois de editar o proto: rode a geração de stubs (Rust via `forge-proto/build.rs` no build normal; +Python via `scripts/gen_proto_py.py`). Comandos em §7. + +### 3.2 Rust — canal de entrada de mensagem do usuário (`squad_agent.rs`) +Replique o padrão do HITL. Passos concretos: + +**(a) Estado pendente de turno do usuário.** Ao lado de `PendingHitl`, o `SquadTaskState` ganha uma +fila/uma espera de mensagem do usuário. Para o MVP 1a, basta uma fila; para 1b (consulta +bloqueante), use o mesmo molde de `oneshot` do HITL: +```rust +struct SquadTaskState { + log: Vec, + tx: Option>, + pending: Option, + // NOVO: mensagens do usuário ainda não consumidas por um ponto de consulta. + inbox: std::collections::VecDeque, + // NOVO (1b): quem está esperando o próximo turno do usuário (consulta bloqueante). + awaiting_user: Option>, +} +``` + +**(b) Métodos no `SquadHub`:** +```rust +impl SquadHub { + /// Registra uma mensagem do usuário. Se houver um ponto de consulta esperando + /// (1b), entrega direto; senão enfileira (1a) para o próximo `await_user_turn`. + pub fn push_user_message(&self, task_id: &str, text: String) -> Result<(), ()> { + let mut tasks = self.tasks.lock().expect("squad hub mutex poisoned"); + let Some(state) = tasks.get_mut(task_id) else { return Err(()); }; + if let Some(waiter) = state.awaiting_user.take() { + let _ = waiter.send(text); + } else { + state.inbox.push_back(text); + } + Ok(()) + } + + /// Chamado pelo CoreBackend quando o orquestrador Python pede o turno do + /// usuário (1b). Se já há mensagem na inbox, retorna na hora; senão espera + /// até `timeout` (ou devolve None p/ "usuário não contribuiu, siga"). + async fn await_user_turn(&self, task_id: &str, timeout: Duration) -> Option { + let rx = { + let mut tasks = self.tasks.lock().expect("squad hub mutex poisoned"); + let state = tasks.get_mut(task_id)?; + if let Some(msg) = state.inbox.pop_front() { return Some(msg); } + let (tx, rx) = tokio::sync::oneshot::channel(); + state.awaiting_user = Some(tx); + rx + }; + match tokio::time::timeout(timeout, rx).await { Ok(Ok(m)) => Some(m), _ => None } + } +} +``` + +**(c) Endpoint HTTP** — em `router()` adicione: +```rust +.route("/api/squad/{task_id}/message", post(post_message_handler)) +``` +```rust +#[derive(Deserialize)] +struct PostMessageBody { text: String } + +async fn post_message_handler( + State(state): State, + Path(task_id): Path, + Json(body): Json, +) -> Response { + // 1) Ecoa a mensagem do usuário no próprio stream, para todos os assinantes + // verem a fala do humano na conversa (mesma UX de qualquer membro). + state.hub.publish(&task_id, /* SquadEvent com ChatMessage{author:"Você", + author_role: HUMAN, text: body.text.clone()} */); + // 2) Entrega/enfileira para o orquestrador consumir. + match state.hub.push_user_message(&task_id, body.text) { + Ok(()) => StatusCode::ACCEPTED.into_response(), + Err(()) => (StatusCode::NOT_FOUND, + Json(ErrorBody::new("task_not_found", "tarefa de squad inexistente"))).into_response(), + } +} +``` +> **Cuidado de contrato (bug real já visto neste repo):** `202 Accepted` tem corpo vazio. No +> frontend, **não** chame `.json()` no corpo de um 202 (a Onda 15 corrigiu exatamente esse bug em +> `fetchJson`). Retorne 202 sem corpo e trate no cliente sem parsear. + +**(d) Back-channel Python→Rust (1b).** O orquestrador Python precisa **puxar** o turno do usuário. +Adicione um RPC ao `CoreService` em `schemas/proto/core.proto` (aditivo): +```proto +service CoreService { + // ... Generate, RunTool, AppendLedger, Recall, Remember, RequestPermission ... + rpc AwaitUserTurn(UserTurnRequest) returns (UserTurnResponse); // NOVO +} +message UserTurnRequest { string task_id = 1; uint32 timeout_ms = 2; } +message UserTurnResponse { bool has_message = 1; string text = 2; } // has_message=false → timeout/skip +``` +No `WebSquadCoreBackend` (impl Rust do CoreService que o server web injeta), implemente +`AwaitUserTurn` chamando `hub.await_user_turn(task_id, timeout)`. É o **espelho exato** de como +`RequestPermission` já é implementado ali (resolve o gate via HTTP em vez de stdin). + +### 3.3 Python — usuário como papel de primeira classe (`forge-squad`) +**(1a MVP)** Sem tocar no fluxo determinístico: apenas repasse as mensagens de chat que o +orquestrador já produz (se produzir) e garanta que falas do usuário aparecem. Como o usuário +posta via Rust (§3.2c) e o Rust já ecoa no stream, o MVP 1a **não exige mudança no Python** — o +chat funciona como "canal lateral" visível, e a contribuição entra como contexto do próximo passo +se você repassá-la (opcional) no próximo prompt. + +**(1b membro pleno)** Adicione **pontos de consulta** no `execute_complex_task`: +```python +# forge_squad/agents/user.py (NOVO) +class UserAgent: + """Representa o humano como membro. Não chama LLM: sua 'proposta' é a + mensagem que o usuário digitou no chat, puxada via CoreService.AwaitUserTurn.""" + def __init__(self, core): # core = cliente gRPC do CoreService (já injetado no orquestrador) + self.core = core + async def contribute(self, task_id, prompt_hint, timeout_ms=60_000): + turn = await self.core.await_user_turn(task_id, timeout_ms) # bloqueia no Rust + return turn.text if turn.has_message else None +``` +No `_get_squad_proposals`, **depois** de coletar as propostas dos agentes e **antes** do consenso, +consulte o usuário e, se ele contribuir, dobre a fala como uma `Proposal` de alto peso (o humano é +o membro sênior): +```python +proposals = { ... } # architect/developer/auditor +user_text = await self.user_agent.contribute(task["task_id"], prompt_hint="revise/oriente a proposta") +if user_text: + proposals["voce"] = Proposal(confidence=1.0, content=user_text) # peso máximo: é o dono + await self._emit({"kind":"chat","author":"Você","author_role":"HUMAN","text":user_text}) +consensus = self.consensus.reach_consensus(proposals, "architecture") +``` +E acrescente o mapeamento `"chat"` em `server.py::_to_squad_event`: +```python +elif kind == "chat": + ev.chat.CopyFrom(squad_pb2.ChatMessage( + author=event["author"], + author_role=squad_pb2.AuthorRole.Value(event["author_role"]), + text=event["text"], + in_reply_to=event.get("in_reply_to", ""), + )) +``` +> **Padrão de fundamentação (portado do btvChatCorp — "montador de system-prompt empilhado").** No +> btvChatCorp, cada turno monta o system prompt empilhando **RAG + instruções-do-projeto + anexos + +> histórico** (arquivo `api/src/routes/chats.rs:528-566`). Aplique o **mesmo empilhamento** ao +> montar o prompt de cada agente do squad: injete as **personas dos outros membros** (inclusive a +> fala do usuário) como contexto, para que os agentes "conversem" cientes uns dos outros. Não é +> preciso abrir o btvChatCorp: o padrão é `system = "\n\n".join([rag_ctx, personas_ctx, +> anexos_ctx, historico])`. + +### 3.4 Frontend — transporte SSE com auth + UI de chat +**(a) Transporte SSE-sobre-`fetch` (portado do btvChatCorp).** O `connectSquadEvents` atual usa +`EventSource` nativo (`web/src/api/squad.ts`). `EventSource` **não envia header `Authorization`** — +quando a plataforma exigir auth no stream (multi-tenant), troque por `fetch`+reader. Padrão +completo (auto-suficiente — é o do `chat-stream.service.ts` do btvChatCorp, adaptado): +```ts +export function connectSquadEvents(taskId: string, handlers: SquadEventHandlers): () => void { + const ctrl = new AbortController() + ;(async () => { + const res = await fetch(`/api/squad/${encodeURIComponent(taskId)}/events`, { + headers: { /* 'Authorization': `Bearer ${token}` quando houver */ }, + signal: ctrl.signal, + }) + const reader = res.body!.getReader() + const dec = new TextDecoder() + let buf = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + let nl + while ((nl = buf.indexOf('\n')) >= 0) { // SSE separa eventos por \n + const line = buf.slice(0, nl); buf = buf.slice(nl + 1) + if (!line.startsWith('data: ')) continue + try { handlers.onEvent(JSON.parse(line.slice(6)) as SquadEventEnvelope) } catch { /* keep-alive */ } + } + } + })().catch(() => handlers.onConnectionError?.()) + return () => ctrl.abort() +} +``` +> Mantenha o `EventSource` atual se a plataforma não precisar de auth no stream ainda; a troca só é +> obrigatória quando o stream ficar autenticado. **Adicione reconnect/backoff** (o hook do IDE não +> tinha) — mas lembre que uma tarefa de squad é **finita**: pare de reconectar no fim do stream. + +**(b) `postSquadMessage` no cliente** (`web/src/api/squad.ts`): +```ts +export async function postSquadMessage(taskId: string, text: string): Promise { + const r = await fetch(`/api/squad/${encodeURIComponent(taskId)}/message`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }), + }) + if (!r.ok) throw new ApiError('falha ao enviar mensagem', `http_${r.status}`) + // 202 sem corpo — NÃO chamar r.json(). +} +``` +Adicione o tipo do novo payload em `SquadEventPayload`: +```ts +export interface SquadChatMessage { author: string; author_role: 'AGENT'|'HUMAN'|'SYSTEM'; text: string; in_reply_to?: string } +export type SquadEventPayload = + | { Proposal: SquadProposal } | { Consensus: SquadConsensus } | { Handoff: SquadHandoff } + | { Hitl: SquadHitl } | { Step: SquadStep } | { Error: string } + | { Chat: SquadChatMessage } // NOVO +``` + +**(c) UI de chat** em `web/src/components/screens/user/Squad.tsx`: derive uma lista `chat` dos +eventos (como já faz para `proposals`/`consensus`), renderize um painel de conversa ao lado da +esteira, e um `