Status de contexto/custo, delegações em andamento (scripts/agentes) e sentinela das cópias CLAUDE.md/AGENTS.md e .claude/.agents skills.

Site completo do RPG Contratados (SCP Foundation RPG v4): calculadora de stats, campanhas, fichas de jogador/criatura/NPC com atualização em tempo real e guia de missão.
Sucessor da contratados-calculadora (arquivada após o milestone M1).
| Arquivo | Papel |
|---|---|
| docs/SYSTEM.SPEC.md | Constituição do projeto — precede tudo |
| docs/CONVENTIONS.md | Referência rápida de convenções de código |
| docs/design/DESIGN.md | Identidade visual, tokens e biblioteca de componentes (frontend/src/app/shared/ui/) |
| docs/context/CONTEXT.md | Estado atual e próxima task |
| docs/context/HISTORY.md | Histórico completo — o que aconteceu e por quê |
| docs/context/PROBLEMS.md | Problemas conhecidos do sistema |
| docs/context/MEMORY.md | Mapa: onde fica o quê e onde estão as regras |
| docs/context/IDEAS.md | Ideias levantadas que ainda não viraram spec |
| docs/SCHEMA.md | Schema SQL alvo + forma dos documentos JSONB |
| docs/DEPLOY.md | Runbook de deploy em produção — ainda descreve o Render; reescrita para Cloud Run pendente (ver docs/context/CONTEXT.md) |
| docs/DEVELOPMENT.md | Banco local reproduzível, fixtures e credenciais de desenvolvimento |
| docs/PARIDADE-M1.md | Verificação de paridade da calculadora (fecha o M1) |
| docs/core/sistema-v4.1.3.md | Fonte da verdade das regras do jogo |
| docs/core/guia_de_mestre-v4.2.0.md | Fonte da verdade de criação de ameaças |
| docs/specs/ | Workflow spec-driven: backlog → active → done |
Com o backend em execução, a documentação navegável da API REST está em /api/docs e o documento OpenAPI bruto em /api/docs-json. As duas rotas são públicas e não usam o envelope StandardResponse das operações de negócio.
O Swagger cobre somente HTTP REST. O Socket.IO continua broadcast-only: mutações entram pela API REST e os eventos permanecem documentados no gateway, fora do OpenAPI. Ao criar, remover ou alterar endpoint ou DTO público, atualize a documentação na mesma task executando npm run openapi:gerar-contratos --workspace=backend e revise o teste de cobertura OpenAPI.
Monorepo npm workspaces:
shared/ (@contratados-rpg/shared) — DTOs, enums, interfaces, validators e o motor de regras do jogo (regras/)backend/ — NestJS + Knex (SQL bruto) + Socket.IO (broadcast-only) → Google Cloud Runfrontend/ — Angular 21 (standalone + Signals), biblioteca de componentes própria em frontend/src/app/shared/ui/ (sem PrimeNG desde ui-05, ver docs/design/DESIGN.md) → Cloudflarenpm install # instala os 3 workspaces
npm run db:up # Postgres via Docker Compose
npm run db:migrate --workspace=backend # migrations
npm run db:reset:dev # apaga/recria o banco local e aplica fixtures
npm run dev # backend + frontend juntos (predev sobe o banco)
dev roda backend:dev (API em http://localhost:3100) e frontend:dev (SPA em http://localhost:4300) em paralelo; rode-os separados se preferir consoles isolados.
O reset é destrutivo e exclusivo do ambiente local. Consulte docs/DEVELOPMENT.md antes do primeiro uso.
DB_HOST DB_PORT DB_NOME DB_USUARIO DB_SENHA
JWT_SECRETO JWT_EXPIRACAO
APP_PORTA APP_AMBIENTE APP_FRONTEND_ORIGEM
Deploy por integração nativa das plataformas: no push para master, um trigger do Cloud Build compila a imagem, roda as migrations e reimplanta o backend no Google Cloud Run, enquanto a Cloudflare Pages puxa do Git e reimplanta o frontend sozinha (banco no Supabase) — sem GitHub Actions no deploy. O backend migrou do Render para o Cloud Run em 2026-09-01 (ver docs/context/HISTORY.md); o runbook docs/DEPLOY.md ainda descreve o fluxo antigo do Render e está pendente de reescrita.
hooks/register.tsx 171 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Delegacao, Painel } from '../types'
5
6const PAINEL = 'painel-orquestracao'
7const INTERVALO_MS = 5000
8const RECENTE_MS = 10 * 60 * 1000
9
10// Pares que o CLAUDE.md exige idênticos ("Sincronização com CLAUDE.md").
11const PARES = [
12 ['CLAUDE.md', 'AGENTS.md'],
13 ['.claude/skills', '.agents/skills'],
14] as const
15const SINCRONIZADO = /(^|[\\/])(CLAUDE\.md|AGENTS\.md)$|[\\/]\.(claude|agents)[\\/]skills[\\/]/
16
17const VAZIO: Painel = { emAndamento: [], ultimas: [], divergencias: [], contexto: null, custo: null }
18const estado = atom({ plugin: 'painel-orquestracao', key: 'estado' } as const, VAZIO)
19
20// Linha TSV de scripts/agentes/_comum.sh (registrar): data destino modo modelo esforço s status hash pasta
21export function lerLinha(linha: string): Delegacao | null {
22 const c = linha.split('\t')
23 if (c.length < 7) return null
24 const [quando = '', destino = '', modo = '', modelo = '', esforco = '', segundos = '0', status = '0'] = c
25 return { quando, destino, modo, modelo, esforco, segundos: Number(segundos), status: Number(status) }
26}
27
28export function resumir(d: Delegacao): string {
29 const ok = d.status === 0 ? 'ok' : `falhou(${d.status})`
30 return `${d.destino} ${d.modo} · ${d.modelo} · ${d.esforco} · ${d.segundos}s · ${ok}`
31}
32
33async function lerDelegacoes($: any): Promise<Delegacao[]> {
34 const texto: string = await $.fs.read('.agentes/delegacoes.log').catch(() => '')
35 return texto.split('\n').map(lerLinha).filter((d): d is Delegacao => d !== null).slice(-8)
36}
37
38async function lerVagas($: any): Promise<string[]> {
39 const itens: { name: string; kind: string }[] = await $.fs.list('.agentes/vagas').catch(() => [])
40 return itens.filter(i => i.kind === 'dir').map(i => i.name.split('.')[0] ?? i.name)
41}
42
43// Depois de um Edit/Write bem-sucedido numa das cópias, revalida os pares; true se foi uma delas.
44async function aposEditar($: any, caminho: string, ran: { deny?: unknown; isError?: boolean }): Promise<boolean> {
45 if (ran.deny !== undefined || ran.isError === true || !SINCRONIZADO.test(caminho)) return false
46 await atualizar($, true)
47 return true
48}
49
50// `git diff --no-index` compara arquivos e pastas; saída 1 = diferem.
51async function verificarCopias($: any): Promise<string[]> {
52 const divergem: string[] = []
53 for (const [a, b] of PARES) {
54 const r = await $.process.run(['git', 'diff', '--no-index', '--quiet', a, b]).catch(() => null)
55 if (r !== null && r.exitCode === 1) divergem.push(`${a} ≠ ${b}`)
56 }
57 return divergem
58}
59
60function textoStatus(p: Painel): string | undefined {
61 const partes: string[] = []
62 if (p.contexto !== null) partes.push(`ctx ${Math.round(p.contexto)}%`)
63 if (p.custo !== null) partes.push(`US$ ${p.custo.toFixed(2)}`)
64 if (p.emAndamento.length > 0) partes.push(`deleg ${p.emAndamento.join('+')}`)
65 if (p.divergencias.length > 0) partes.push('⚠ cópias divergem')
66 return partes.length > 0 ? partes.join(' · ') : undefined
67}
68
69// Resumo em texto puro, para o chat de qualquer interface (o painel lateral pode não existir nela).
70export function relatorio(p: Painel): string {
71 const linhas = [
72 `Contexto: ${p.contexto === null ? '—' : `${Math.round(p.contexto)}%`} · Custo: ${p.custo === null ? '—' : `US$ ${p.custo.toFixed(2)}`}`,
73 `Cópias CLAUDE.md/AGENTS.md e skills: ${p.divergencias.length === 0 ? 'idênticas' : `DIVERGEM — ${p.divergencias.join('; ')}`}`,
74 `Delegações em andamento: ${p.emAndamento.length === 0 ? 'nenhuma' : p.emAndamento.join(', ')}`,
75 'Últimas delegações:',
76 ...(p.ultimas.length === 0 ? [' nenhuma registrada em .agentes/delegacoes.log'] : [...p.ultimas].reverse().map(d => ` ${resumir(d)}`)),
77 ]
78 return linhas.join('\n')
79}
80
81// Atualiza o que é barato (arquivos e uso da sessão); a checagem de cópias é separada.
82async function atualizar($: any, verificar: boolean) {
83 const uso = await $.session.usage().catch(() => null)
84 const [ultimas, emAndamento] = await Promise.all([lerDelegacoes($), lerVagas($)])
85 const divergencias = verificar ? await verificarCopias($) : (await read($, estado)).divergencias
86 const novo: Painel = {
87 emAndamento,
88 ultimas,
89 divergencias,
90 contexto: uso?.context?.percent ?? null,
91 custo: uso?.cost?.usd ?? null,
92 }
93 await update($, estado, () => novo)
94 $.ui.status(textoStatus(novo))
95}
96
97export const register: Register = on => {
98 let tocouCopias = false
99
100 on('session.start', async ($, e, next) => {
101 await $.command.register({ name: 'painel', description: 'Abre o painel de delegações, contexto e cópias sincronizadas' })
102 void atualizar($, true)
103 $.clock.every(INTERVALO_MS, () => atualizar($, false))
104 return next(e)
105 })
106
107 on('command.run', { command: 'painel' }, async $ => {
108 await atualizar($, true)
109 await $.ui.open({ id: PAINEL, title: 'Orquestração' })
110 return { text: relatorio(await read($, estado)) }
111 })
112
113 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
114 const ran = await next(e)
115 if (await aposEditar($, e.file_path, ran)) tocouCopias = true
116 return ran
117 })
118
119 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
120 const ran = await next(e)
121 if (await aposEditar($, e.file_path, ran)) tocouCopias = true
122 return ran
123 })
124
125 // Entre editar uma cópia e a outra é normal divergir; só avisa se o turno terminou assim.
126 on('turn.complete', async ($, e, next) => {
127 if (tocouCopias) {
128 tocouCopias = false
129 await atualizar($, true)
130 const { divergencias } = await read($, estado)
131 if (divergencias.length > 0) $.ui.toast(`Cópias divergem: ${divergencias.join('; ')}`)
132 }
133 return next(e)
134 })
135
136 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
137 const p = await read($, estado)
138 const agora = await $.clock.now()
139 const ultima = p.ultimas[p.ultimas.length - 1]
140 const recente = ultima !== undefined && agora - Date.parse(ultima.quando) < RECENTE_MS
141 if (e.props.hasSurvey) return next(e)
142 const { Box, Text } = $.ui.resolve(e)
143 return (
144 <Box flexDirection="column">
145 {p.divergencias.length > 0 && <Text color="red">⚠ Cópias divergem: {p.divergencias.join('; ')}</Text>}
146 {p.emAndamento.length > 0 && <Text>Delegando agora: {p.emAndamento.join(', ')}</Text>}
147 {p.emAndamento.length === 0 && recente && ultima !== undefined && <Text dimColor>Última delegação: {resumir(ultima)}</Text>}
148 <Text dimColor>{textoStatus(p) ?? 'painel-orquestracao ativo'}</Text>
149 </Box>
150 )
151 })
152
153 on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
154 const p = await read($, estado)
155 const { Box, Text } = $.ui.resolve(e)
156 return (
157 <Box flexDirection="column">
158 <Text bold>Sessão</Text>
159 <Text>Contexto: {p.contexto === null ? '—' : `${Math.round(p.contexto)}%`} · Custo: {p.custo === null ? '—' : `US$ ${p.custo.toFixed(2)}`}</Text>
160 <Text bold>Cópias sincronizadas</Text>
161 {p.divergencias.length === 0 ? <Text dimColor>idênticas</Text> : p.divergencias.map(d => <Text color="red">{d}</Text>)}
162 <Text bold>Delegações em andamento</Text>
163 {p.emAndamento.length === 0 ? <Text dimColor>nenhuma</Text> : <Text>{p.emAndamento.join(', ')}</Text>}
164 <Text bold>Últimas delegações</Text>
165 {p.ultimas.length === 0 && <Text dimColor>nenhuma registrada em .agentes/delegacoes.log</Text>}
166 {[...p.ultimas].reverse().map(d => <Text dimColor={d.status === 0}>{resumir(d)}</Text>)}
167 </Box>
168 )
169 })
170}
171types/index.d.ts 29 lines1export type Delegacao = {
2 quando: string
3 destino: string
4 modo: string
5 modelo: string
6 esforco: string
7 segundos: number
8 status: number
9}
10
11export type Painel = {
12 /** Vagas ocupadas em .agentes/vagas, ex.: "codex" (uma entrada por vaga). */
13 emAndamento: string[]
14 /** Últimas linhas de .agentes/delegacoes.log, da mais antiga para a mais nova. */
15 ultimas: Delegacao[]
16 /** Pares de cópias idênticas que divergem, ex.: "CLAUDE.md ≠ AGENTS.md". */
17 divergencias: string[]
18 /** Preenchimento da janela de contexto (0–100), se conhecido. */
19 contexto: number | null
20 /** Custo da sessão em USD, se conhecido. */
21 custo: number | null
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'painel-orquestracao': { estado: Painel }
27 }
28}
29