Dá nota a cada resposta do Claude com o Jev da TypeSafe e sugere regras para o CLAUDE.md.

🇺🇸 English version: README_EN-us.md
Plugin do Claude Code que dá nota a cada resposta do Claude e sugere regras para o CLAUDE.md. Quem corrige a prova é o Jev, o modelo da TypeSafe. Para você descobrir que o Claude esqueceu de rodar os testes antes de o deploy te contar.
Quando um turno do Claude termina, o plugin manda para o Jev o pedido, os arquivos mexidos, os comandos rodados e a resposta final. O Jev avalia cada critério do criterios.json em três degraus (fraco, ok, ótimo). Os seis critérios que vêm de fábrica:
| Critério | Pergunta |
|---|---|
| Resumo final | Disse o que mudou e em quais arquivos? |
| Como conferir | Disse qual página abrir e o que clicar? |
| Prova | Rodou testes ou abriu a página e disse o resultado? |
| Suposições | Avisou o que decidiu sozinho? |
| Tamanho da mudança | Mexeu só no que o pedido exigia? |
| Próximo passo | Sugeriu um próximo passo concreto? |
A nota do turno é a média (ótimo 10, ok 6, fraco 2). Os dois critérios mais fracos viram pontos de melhoria, cada um com uma regra sugerida. O plugin só observa: nunca segura nem altera uma ferramenta.
Onde o boletim aparece:
Boletim: nota 7,3 · melhorar: Prova (fraco), Como conferir (ok);| Comando | O que faz |
|---|---|
/boletim | Abre o painel com o boletim do último turno. |
/melhorar | Grava no CLAUDE.md da raiz as regras dos pontos de melhoria, na seção ## Regras do boletim, sem duplicar. |
~/.env.local (na sua home), lida a cada avaliação: TYPESAFE_API_KEY=sua-chave
Aceita export, aspas e comentário no fim da linha. A chave nunca aparece na tela, nem em mensagem de erro.
/plugin install boletim-do-claude --marketplace maiquealmeida/skills
De um clone local, para testar ou editar (vale só para aquela sessão):
claude --plugin-dir plugins/boletim-do-claude
O criterios.json fica na raiz do plugin e é lido de novo a cada turno. Cada critério tem nome, pergunta, regra e, se quiser, degraus com o que é fraco, ok e ótimo. Um critério sem nome, pergunta ou regra é ignorado, e uma lista vazia deixa o turno sem nota. O campo _como_editar explica cada campo dentro do próprio arquivo.
Instalado pelo marketplace, o plugin roda de uma cópia. Uma atualização pode sobrescrever o criterios.json dessa cópia. Para critérios só seus, use o clone com --plugin-dir.
A cada turno principal, o plugin envia para api.typesafe.ai o pedido (até 2000 caracteres), os arquivos mexidos (até 50), os últimos 30 comandos (cada um cortado em 300 caracteres) e o fim da resposta (até 8000 caracteres). Se algum projeto não pode sair da máquina, não carregue o plugin nele.
Sem chave, sem rede, resposta fora do formato ou criterios.json quebrado, o turno fica sem nota (Boletim: Jev fora do ar, turno sem nota). O motivo vai só para o log de debug (claude --debug).
claude plugin validate plugins/boletim-do-claude
claude plugin test plugins/boletim-do-claude
Com o plugin carregado de uma pasta (--plugin-dir), o Claude Code grava .claude-plugin/types/ e o tsc -p plugins/boletim-do-claude passa a funcionar. Essa pasta é gerada e fica fora do git. O validate avisa que o nome "parece um nome da Anthropic" (por causa do "claude"); o nome foi mantido de propósito.
| Caminho | Papel |
|---|---|
hooks/register.tsx | Tudo o que toca no motor: eventos, estado, arquivos, rede, tela e comandos. |
hooks/jev.ts | Lógica pura: requisição ao Jev, notas, gráfico, textos do boletim e edição do CLAUDE.md. |
types/index.d.ts | Contrato do $.state (turno, avaliação, histórico e onde o boletim aparece). |
criterios.json | Critérios editáveis, com os degraus de cada um. |
tests/ | Motor falso (Jev, arquivos, store, relógio, painel) e os testes do plugin. |
hooks/register.tsx 313 lines1// Só o que toca no motor: eventos, estado, arquivos, rede, tela e comandos.
2// Cálculo, texto e CLAUDE.md ficam em ./jev, puros.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, ResolveInput } from 'claude-code'
6
7import type { Avaliacao } from '../types'
8import {
9 acrescentarNota,
10 aplicarRegistro,
11 avaliacaoPronta,
12 esconder,
13 incluirRegras,
14 iniciarTurno,
15 lerChave,
16 lerRespostas,
17 linhaAcimaDoPrompt,
18 linhaDeConversa,
19 montarBoletim,
20 montarRequisicao,
21 NOME_DA_CHAVE,
22 notasValidas,
23 parseCriterios,
24 planejarMelhorar,
25 registroDaFerramenta,
26 textoRegrasGravadas,
27 textoRegrasJaExistem,
28 TURNO_VAZIO,
29 URL_DO_JEV,
30} from './jev'
31import type { Linha, Retrato, TomDaLinha, Trecho } from './jev'
32
33const ID_DO_PAINEL = 'boletim-do-claude'
34const TITULO_DO_PAINEL = 'Boletim do Claude'
35const ARQUIVO_DE_CHAVES = '.env.local'
36const ARQUIVO_DE_REGRAS = 'CLAUDE.md'
37const ARQUIVO_DE_CRITERIOS = 'criterios.json'
38const CHAVE_DO_HISTORICO = 'historico'
39const MAX_CORPO_NO_LOG = 300
40
41const turno = atom({ plugin: 'boletim-do-claude', key: 'turno' } as const, TURNO_VAZIO)
42const avaliacao = atom({ plugin: 'boletim-do-claude', key: 'avaliacao' } as const, null)
43const historico = atom({ plugin: 'boletim-do-claude', key: 'historico' } as const, [])
44const onde = atom({ plugin: 'boletim-do-claude', key: 'onde' } as const, null)
45
46const barras = (caminho: string): string => caminho.replace(/\\/g, '/')
47
48const mensagemDe = (erro: unknown): string => (erro instanceof Error ? erro.message : String(erro))
49
50/** Detalhe de erro: só vai para o log de debug, e sem a chave. */
51function depurar($: EngineInterface, texto: string, chave?: string): void {
52 $.ui.log(esconder(texto, chave), { to: 'debug' })
53}
54
55/** Roda a tarefa e engole o erro (com registro no debug): o boletim só observa, nunca atrapalha. */
56async function protegido($: EngineInterface, contexto: string, tarefa: () => Promise<unknown>): Promise<void> {
57 try {
58 await tarefa()
59 } catch (erro) {
60 depurar($, `${contexto}: ${mensagemDe(erro)}`)
61 }
62}
63
64/** Abre o painel e anota onde o boletim vai aparecer. Sem espaço, ele espera aberto e a faixa assume. */
65async function abrirPainel($: EngineInterface): Promise<boolean> {
66 const { isPlaced } = await $.ui.open({ id: ID_DO_PAINEL, title: TITULO_DO_PAINEL })
67 await update($, onde, () => (isPlaced ? 'painel' : 'faixa'))
68 return isPlaced
69}
70
71// ── Avaliação ───────────────────────────────────────────────────────────────
72
73/** A pasta home de quem roda o Claude: HOME, ou USERPROFILE onde não há HOME (Windows). Vazio conta como ausente. */
74async function pastaHome($: EngineInterface): Promise<string> {
75 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
76 if (!home) throw new Error('HOME e USERPROFILE não estão definidos')
77 return barras(home)
78}
79
80/** A chave fica no ~/.env.local, para valer em qualquer projeto; é lida de novo a cada avaliação. */
81async function lerChaveDaHome($: EngineInterface): Promise<string | undefined> {
82 try {
83 const caminho = `${await pastaHome($)}/${ARQUIVO_DE_CHAVES}`
84 const chave = lerChave(await $.fs.read(caminho))
85 if (chave === undefined) throw new Error(`${NOME_DA_CHAVE} não está em ${caminho}`)
86 return chave
87 } catch (erro) {
88 depurar($, `Jev sem chave: ${mensagemDe(erro)}`)
89 return undefined
90 }
91}
92
93async function consultarJev($: EngineInterface, retrato: Retrato, id: string, chave: string): Promise<Avaliacao> {
94 try {
95 const criterios = parseCriterios(await $.fs.read(`${barras($.plugin.root)}/${ARQUIVO_DE_CRITERIOS}`))
96 const inicio = await $.clock.now()
97 const resposta = await $.http.fetch(URL_DO_JEV, {
98 method: 'POST',
99 headers: { Authorization: `Bearer ${chave}`, 'Content-Type': 'application/json' },
100 body: JSON.stringify(montarRequisicao(retrato, criterios)),
101 })
102 const ms = (await $.clock.now()) - inicio
103 // Esconde a chave antes de qualquer corte: cortado no meio dela, o pedaço que sobra não casaria.
104 const corpo = esconder(resposta.text, chave)
105 if (!resposta.ok) {
106 throw new Error(`HTTP ${resposta.status}: ${corpo.slice(0, MAX_CORPO_NO_LOG)}`)
107 }
108 return avaliacaoPronta(id, lerRespostas(corpo, criterios), ms)
109 } catch (erro) {
110 depurar($, `Jev fora do ar: ${mensagemDe(erro)}`, chave)
111 return { estado: 'fora', id }
112 }
113}
114
115async function guardarNota($: EngineInterface, nota: number): Promise<void> {
116 const notas = await update($, historico, antes => acrescentarNota(antes, nota))
117 await $.store.set(CHAVE_DO_HISTORICO, notas)
118}
119
120/** Grava o resultado, avisa na conversa e põe a nota no histórico. */
121async function concluir($: EngineInterface, id: string, final: Avaliacao): Promise<void> {
122 // Se um turno mais novo já começou a ser avaliado, o resultado deste não toma o lugar dele.
123 await protegido($, 'avaliação', () =>
124 update($, avaliacao, atual => (atual !== null && atual.id !== id ? atual : final)),
125 )
126 $.ui.log(linhaDeConversa(final))
127 if (final.estado === 'ok') {
128 await protegido($, 'histórico', () => guardarNota($, final.nota))
129 }
130}
131
132async function avaliar($: EngineInterface, retrato: Retrato, id: string): Promise<void> {
133 const chave = await lerChaveDaHome($)
134 const final: Avaliacao =
135 chave === undefined ? { estado: 'fora', id } : await consultarJev($, retrato, id, chave)
136 await concluir($, id, final)
137}
138
139// ── Comandos ────────────────────────────────────────────────────────────────
140
141async function recuperarHistorico($: EngineInterface): Promise<void> {
142 if ((await read($, historico)).length > 0) return
143 const notas = notasValidas(await $.store.get(CHAVE_DO_HISTORICO))
144 if (notas.length > 0) await update($, historico, () => notas)
145}
146
147/** Recarregar o mod derruba a avaliação que estava em andamento: ela não vai mais terminar. */
148async function limparAvaliacaoPerdida($: EngineInterface): Promise<void> {
149 await update($, avaliacao, atual => (atual?.estado === 'avaliando' ? null : atual))
150}
151
152async function gravarRegras($: EngineInterface): Promise<string> {
153 const plano = planejarMelhorar(await read($, avaliacao))
154 if (plano.tipo === 'aviso') return plano.texto
155 try {
156 const caminho = `${barras(await $.session.root())}/${ARQUIVO_DE_REGRAS}`
157 const conteudo = (await $.fs.exists(caminho)) ? await $.fs.read(caminho) : ''
158 const incluidas = incluirRegras(conteudo, plano.regras)
159 if (incluidas.adicionadas.length === 0) return textoRegrasJaExistem(plano.regras)
160 await $.fs.write(caminho, incluidas.conteudo)
161 return textoRegrasGravadas(incluidas.adicionadas, incluidas.jaExistiam)
162 } catch (erro) {
163 depurar($, `/melhorar: ${mensagemDe(erro)}`)
164 return 'Não consegui gravar no CLAUDE.md (detalhe no log de debug).'
165 }
166}
167
168async function abrirBoletim($: EngineInterface): Promise<string> {
169 try {
170 const isPlaced = await abrirPainel($)
171 return isPlaced
172 ? 'Boletim do Claude aberto ao lado da conversa.'
173 : 'Sem espaço pro painel: o boletim aparece acima do prompt.'
174 } catch (erro) {
175 depurar($, `/boletim: ${mensagemDe(erro)}`)
176 return 'Não consegui abrir o painel do boletim (detalhe no log de debug).'
177 }
178}
179
180// ── Desenho ─────────────────────────────────────────────────────────────────
181
182function estiloDoTrecho(trecho: Trecho) {
183 return {
184 ...(trecho.negrito ? { bold: true } : {}),
185 ...(trecho.cor === undefined ? {} : { color: trecho.cor }),
186 ...(trecho.discreto ? { dimColor: true } : {}),
187 }
188}
189
190function estiloDaLinha(tom: TomDaLinha) {
191 if (tom === 'discreto') return { dimColor: true }
192 if (tom === 'aviso') return { color: 'yellow' }
193 return {}
194}
195
196/** Uma linha do boletim vira um Text; os trechos com estilo viram Texts dentro dele. */
197function desenharLinhas($: EngineInterface, e: ResolveInput, linhas: readonly Linha[]) {
198 const { Text } = $.ui.resolve(e)
199 return linhas.map(linha => (
200 <Text>
201 {linha.length === 0
202 ? ' '
203 : linha.map(trecho => {
204 const estilo = estiloDoTrecho(trecho)
205 return Object.keys(estilo).length === 0 ? trecho.texto : <Text {...estilo}>{trecho.texto}</Text>
206 })}
207 </Text>
208 ))
209}
210
211export const register: Register = on => {
212 on('session.start', async ($, e, next) => {
213 await protegido($, 'comando /boletim', () =>
214 $.command.register({ name: 'boletim', description: 'Abre o boletim do Claude: a nota do último turno' }),
215 )
216 await protegido($, 'comando /melhorar', () =>
217 $.command.register({
218 name: 'melhorar',
219 description: 'Grava no CLAUDE.md as regras sugeridas pelo último boletim',
220 }),
221 )
222 await protegido($, 'histórico', () => recuperarHistorico($))
223 await protegido($, 'avaliação perdida', () => limparAvaliacaoPerdida($))
224 return next(e)
225 })
226
227 on('prompt.submit', async ($, e, next) => {
228 const entrou = await next(e)
229 if (entrou.drop === undefined) {
230 await protegido($, 'painel', () => abrirPainel($))
231 }
232 return entrou
233 })
234
235 on('turn.start', async ($, e, next) => {
236 const comecou = await next(e)
237 await protegido($, 'turno', () => update($, turno, antes => iniciarTurno(antes, e.text)))
238 return comecou
239 })
240
241 // Só observa: a ferramenta roda do jeito que o modelo pediu e o resultado volta intacto.
242 on('tool.call', async ($, e, next) => {
243 const rodou = await next(e)
244 const ok = rodou.deny === undefined && rodou.isError !== true
245 await protegido($, 'ferramenta', async () => {
246 const registro = registroDaFerramenta(String(e.tool), e, ok)
247 if (registro !== null) await update($, turno, atual => aplicarRegistro(atual, registro))
248 })
249 return rodou
250 })
251
252 on('turn.complete', async ($, e, next) => {
253 const completou = await next(e)
254 if (e.agentId !== undefined || e.reason !== 'answer') return completou
255 await protegido($, 'boletim', async () => {
256 const atual = await read($, turno)
257 const retrato: Retrato = {
258 pedido: atual.pedido,
259 arquivos: atual.arquivos,
260 comandos: atual.comandos,
261 resposta: e.answer,
262 }
263 await update($, avaliacao, () => ({ estado: 'avaliando', id: e.turnId }))
264 await protegido($, 'painel', () => abrirPainel($))
265 // Fora do fim do turno: a avaliação corre sozinha e não segura nada.
266 $.clock.after(0, () => {
267 avaliar($, retrato, e.turnId).catch(erro => depurar($, `avaliação: ${mensagemDe(erro)}`))
268 })
269 })
270 return completou
271 })
272
273 // O mod nunca fecha o painel, então um fechamento é sempre da pessoa: sem painel e sem faixa
274 // até o próximo turno, que abre de novo.
275 on('ui.close', { id: ID_DO_PAINEL }, async ($, e, next) => {
276 const fechou = await next(e)
277 await protegido($, 'painel fechado', () => update($, onde, () => null))
278 return fechou
279 })
280
281 on('ui.render', { component: 'Pane', requestId: ID_DO_PAINEL }, async ($, e) => {
282 const { Box } = $.ui.resolve(e)
283 const linhas = montarBoletim(await read($, avaliacao), await read($, historico))
284 return <Box flexDirection="column">{desenharLinhas($, e, linhas)}</Box>
285 })
286
287 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288 const base = await next(e)
289 const atual = await read($, avaliacao)
290 if (e.props.hasSurvey || atual === null) return base
291
292 const { Box, Text } = $.ui.resolve(e)
293 const linha = linhaAcimaDoPrompt(atual)
294 const isFaixa = atual.estado === 'ok' && (await read($, onde)) === 'faixa'
295 const notas = await read($, historico)
296 return (
297 <Box flexDirection="column">
298 {base}
299 {isFaixa && (
300 <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
301 {desenharLinhas($, e, montarBoletim(atual, notas))}
302 </Box>
303 )}
304 <Text {...estiloDaLinha(linha.tom)}>{linha.texto}</Text>
305 </Box>
306 )
307 })
308
309 on('command.run', { command: 'boletim' }, async $ => ({ text: await abrirBoletim($) }))
310
311 on('command.run', { command: 'melhorar' }, async $ => ({ text: await gravarRegras($) }))
312}
313hooks/jev.ts 567 lines1// Tudo o que não precisa do motor: requisição ao Jev, notas, gráfico, textos do
2// boletim e edição do CLAUDE.md. Sem `$`, sem relógio, sem arquivo: só entra
3// valor e sai valor, para dar para testar sem motor.
4
5import type { Avaliacao, Comando, Degrau, Resultado, Turno } from '../types'
6
7export type Criterio = {
8 nome: string
9 pergunta: string
10 regra: string
11 degraus: Readonly<Record<Degrau, string>>
12}
13
14/** O turno como o Jev o vê: o que foi pedido, o que foi feito e o que foi respondido. */
15export type Retrato = {
16 pedido: string
17 arquivos: readonly string[]
18 comandos: readonly Comando[]
19 resposta: string
20}
21
22export type AvaliacaoPronta = Extract<Avaliacao, { estado: 'ok' }>
23
24export type PerguntaDoJev = {
25 type: 'score'
26 instructions: { tarefa: string; criterio: string; pergunta: string }
27 criteria: string[]
28}
29
30export type RequisicaoDoJev = {
31 model: string
32 state: {
33 pedido: string
34 arquivos: string[]
35 comandos: { comando: string; resultado: 'deu certo' | 'falhou'; teste: boolean }[]
36 resposta: string
37 }
38 questions: Record<string, PerguntaDoJev>
39}
40
41/** Um pedaço de texto com estilo. Uma linha do boletim é uma lista deles. */
42export type Trecho = {
43 texto: string
44 cor?: 'green' | 'red' | 'yellow'
45 negrito?: boolean
46 discreto?: boolean
47}
48
49export type Linha = readonly Trecho[]
50
51export type TomDaLinha = 'normal' | 'discreto' | 'aviso'
52
53/** O que uma chamada de ferramenta deixa no retrato do turno. */
54export type Registro = { arquivo: string } | { comando: Comando }
55
56/** O que o /melhorar faz: avisa que não há o que gravar, ou grava estas regras. */
57export type Plano = { tipo: 'aviso'; texto: string } | { tipo: 'regras'; regras: string[] }
58
59export type RegrasIncluidas = {
60 conteudo: string
61 adicionadas: string[]
62 jaExistiam: string[]
63}
64
65export const URL_DO_JEV = 'https://api.typesafe.ai/v1/systemone'
66export const MODELO_DO_JEV = 'jev-latest'
67export const NOME_DA_CHAVE = 'TYPESAFE_API_KEY'
68export const TITULO_DA_SECAO = '## Regras do boletim'
69export const TURNO_VAZIO: Turno = { pedido: '', arquivos: [], comandos: [] }
70export const DEGRAUS: readonly Degrau[] = ['fraco', 'ok', 'ótimo']
71
72const PONTOS_DO_DEGRAU: Readonly<Record<Degrau, number>> = { fraco: 2, ok: 6, ótimo: 10 }
73const DEGRAUS_GENERICOS: Readonly<Record<Degrau, string>> = {
74 fraco: 'não atende ao critério',
75 ok: 'atende em parte',
76 ótimo: 'atende bem',
77}
78const COR_DO_DEGRAU: Readonly<Record<Degrau, Trecho['cor']>> = {
79 fraco: 'red',
80 ok: undefined,
81 ótimo: 'green',
82}
83const TAREFA =
84 'Você avalia a resposta final de um assistente de programação (o Claude Code) a um pedido do usuário. ' +
85 'O state descreve o turno: "pedido" é o que o usuário pediu; "arquivos" são os arquivos que o assistente alterou; ' +
86 '"comandos" são os comandos de shell que ele rodou, cada um com o "resultado" ("deu certo" ou "falhou") e ' +
87 '"teste" (true quando é teste, tipagem, lint ou build); "resposta" é a mensagem final que o usuário leu. ' +
88 'Julgue só o critério abaixo, usando apenas esses dados.'
89
90const FERRAMENTAS_DE_ARQUIVO: ReadonlySet<string> = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
91const FERRAMENTAS_DE_SHELL: ReadonlySet<string> = new Set(['Bash', 'PowerShell'])
92const PADRAO_DE_TESTE = /test|tests|vitest|jest|pytest|mocha|playwright|cypress|tsc|lint|eslint|build/i
93
94const NOTA_MAXIMA = 10
95const CONFIANCA_BAIXA = 0.6
96const MAX_PEDIDO = 2000
97const MAX_RESPOSTA = 8000
98const MAX_COMANDO = 300
99const MAX_GUARDADOS = 50
100const MAX_ARQUIVOS_ENVIADOS = 50
101const MAX_COMANDOS_ENVIADOS = 30
102const MAX_HISTORICO = 20
103const MAX_MELHORIAS = 2
104const MAX_NOTAS_NO_TEXTO = 6
105const MAX_TRECHO_DO_CORPO = 200
106const LARGURA_DA_BARRA = 10
107const LARGURA_DO_DEGRAU = 5
108const ESCALA_DO_GRAFICO = '▁▂▃▄▅▆▇█'
109const SEPARADOR_DE_NOTAS = ' → '
110const LINHA_EM_BRANCO: Linha = []
111
112const ehObjeto = (valor: unknown): valor is Record<string, unknown> =>
113 typeof valor === 'object' && valor !== null && !Array.isArray(valor)
114
115const ehTexto = (valor: unknown): valor is string => typeof valor === 'string' && valor.trim() !== ''
116
117function lerJson(texto: string, origem: string): unknown {
118 try {
119 return JSON.parse(texto)
120 } catch {
121 throw new Error(`${origem} não é um JSON válido`)
122 }
123}
124
125// ── Critérios (criterios.json) ──────────────────────────────────────────────
126
127function degrausDe(bruto: unknown): Record<Degrau, string> {
128 const dado = ehObjeto(bruto) ? bruto : {}
129 const textoDe = (degrau: Degrau): string => {
130 const valor = dado[degrau]
131 return ehTexto(valor) ? valor.trim() : DEGRAUS_GENERICOS[degrau]
132 }
133 return { fraco: textoDe('fraco'), ok: textoDe('ok'), ótimo: textoDe('ótimo') }
134}
135
136function criterioDe(item: unknown): Criterio[] {
137 if (!ehObjeto(item) || !ehTexto(item.nome) || !ehTexto(item.pergunta) || !ehTexto(item.regra)) {
138 return []
139 }
140 return [
141 {
142 nome: item.nome.trim(),
143 pergunta: item.pergunta.trim(),
144 regra: item.regra.trim(),
145 degraus: degrausDe(item.degraus),
146 },
147 ]
148}
149
150/** Lê o criterios.json. Item sem nome, pergunta ou regra é ignorado; lista vazia é erro. */
151export function parseCriterios(texto: string): Criterio[] {
152 const bruto = lerJson(texto, 'criterios.json')
153 const lista = ehObjeto(bruto) ? bruto.criterios : undefined
154 if (!Array.isArray(lista)) {
155 throw new Error('criterios.json precisa de uma lista "criterios"')
156 }
157 const criterios = lista.flatMap(criterioDe)
158 if (criterios.length === 0) {
159 throw new Error('criterios.json não tem nenhum critério válido')
160 }
161 return criterios
162}
163
164// ── Chave (~/.env.local) ────────────────────────────────────────────────────
165
166const LINHA_DA_CHAVE = new RegExp(`^\\s*(?:export\\s+)?${NOME_DA_CHAVE}\\s*=\\s*(.*)$`)
167
168function valorSemAspas(bruto: string): string {
169 const texto = bruto.trim()
170 const aspas = texto[0]
171 if (aspas === '"' || aspas === "'") {
172 const fim = texto.indexOf(aspas, 1)
173 return fim === -1 ? texto.slice(1) : texto.slice(1, fim)
174 }
175 const comentario = texto.search(/\s#/)
176 return (comentario === -1 ? texto : texto.slice(0, comentario)).trim()
177}
178
179/** Acha TYPESAFE_API_KEY num .env: aceita `export`, aspas e comentário no fim da linha. */
180export function lerChave(texto: string): string | undefined {
181 const valores = texto
182 .split(/\r?\n/)
183 .map(linha => LINHA_DA_CHAVE.exec(linha)?.[1])
184 .filter((valor): valor is string => valor !== undefined)
185 .map(valorSemAspas)
186 .filter(valor => valor !== '')
187 return valores.at(-1)
188}
189
190/** Troca a chave por *** em qualquer texto que vá para um log. */
191export function esconder(texto: string, chave: string | undefined): string {
192 if (chave === undefined || chave === '') return texto
193 return texto.split(chave).join('***')
194}
195
196// ── Requisição e resposta do Jev ────────────────────────────────────────────
197
198export const idDoCriterio = (posicao: number): string => `c${posicao}`
199
200const fimDoTexto = (texto: string, max: number): string => (texto.length > max ? texto.slice(-max) : texto)
201
202function perguntaDe(criterio: Criterio): PerguntaDoJev {
203 const { fraco, ok, ótimo } = criterio.degraus
204 return {
205 type: 'score',
206 instructions: { tarefa: TAREFA, criterio: criterio.nome, pergunta: criterio.pergunta },
207 criteria: [`fraco: ${fraco}`, `ok: ${ok}`, `ótimo: ${ótimo}`],
208 }
209}
210
211/** O corpo do POST: uma pergunta "score" por critério, com o retrato do turno no state. */
212export function montarRequisicao(retrato: Retrato, criterios: readonly Criterio[]): RequisicaoDoJev {
213 return {
214 model: MODELO_DO_JEV,
215 state: {
216 pedido: retrato.pedido.slice(0, MAX_PEDIDO),
217 arquivos: retrato.arquivos.slice(-MAX_ARQUIVOS_ENVIADOS),
218 comandos: retrato.comandos.slice(-MAX_COMANDOS_ENVIADOS).map(item => ({
219 comando: item.comando,
220 resultado: item.ok ? 'deu certo' : 'falhou',
221 teste: item.teste,
222 })),
223 // O fim da resposta é onde estão o resumo e o próximo passo, então é o que fica.
224 resposta: fimDoTexto(retrato.resposta, MAX_RESPOSTA),
225 },
226 questions: Object.fromEntries(criterios.map((criterio, posicao) => [idDoCriterio(posicao), perguntaDe(criterio)])),
227 }
228}
229
230/** O degrau é a posição ARREDONDADA na escala 0..2, não o degrau que ficou na frente. */
231export function degrauDoScore(score: number): Degrau {
232 const posicao = Math.round(score)
233 if (posicao <= 0) return 'fraco'
234 if (posicao >= 2) return 'ótimo'
235 return 'ok'
236}
237
238const confiancaDe = (valor: unknown): number =>
239 typeof valor === 'number' && Number.isFinite(valor) ? Math.min(1, Math.max(0, valor)) : 0
240
241function resultadoDe(criterio: Criterio, posicao: number, resposta: unknown): Resultado {
242 if (!ehObjeto(resposta) || typeof resposta.score !== 'number' || !Number.isFinite(resposta.score)) {
243 throw new Error(`a resposta do Jev não traz o critério ${idDoCriterio(posicao)}`)
244 }
245 return {
246 nome: criterio.nome,
247 degrau: degrauDoScore(resposta.score),
248 confianca: confiancaDe(resposta.confidence),
249 regra: criterio.regra,
250 }
251}
252
253function lerCorpoDoJev(texto: string): unknown {
254 try {
255 return JSON.parse(texto)
256 } catch {
257 // O começo do corpo ajuda a achar o problema (uma página de erro do gateway, por exemplo).
258 throw new Error(`a resposta do Jev não é um JSON válido: ${texto.slice(0, MAX_TRECHO_DO_CORPO)}`)
259 }
260}
261
262/** Lê o corpo da resposta do Jev. Falta de qualquer critério é erro. */
263export function lerRespostas(texto: string, criterios: readonly Criterio[]): Resultado[] {
264 const corpo = lerCorpoDoJev(texto)
265 const respostas = ehObjeto(corpo) && ehObjeto(corpo.answers) ? corpo.answers : undefined
266 if (respostas === undefined) {
267 throw new Error('a resposta do Jev não traz "answers"')
268 }
269 return criterios.map((criterio, posicao) => resultadoDe(criterio, posicao, respostas[idDoCriterio(posicao)]))
270}
271
272// ── Nota, melhorias e histórico ─────────────────────────────────────────────
273
274/** Média dos pontos (ótimo 10, ok 6, fraco 2), com uma casa decimal. */
275export function notaDoTurno(resultados: readonly Resultado[]): number {
276 if (resultados.length === 0) return 0
277 const soma = resultados.reduce((total, resultado) => total + PONTOS_DO_DEGRAU[resultado.degrau], 0)
278 return Math.round((soma * 10) / resultados.length) / 10
279}
280
281/** Os critérios abaixo de ótimo, do mais baixo para o mais alto (empate: pela posição), até 2. */
282export function pontosDeMelhoria(resultados: readonly Resultado[]): Resultado[] {
283 return resultados
284 .map((resultado, posicao) => ({ resultado, posicao }))
285 .filter(({ resultado }) => resultado.degrau !== 'ótimo')
286 .sort(
287 (a, b) =>
288 DEGRAUS.indexOf(a.resultado.degrau) - DEGRAUS.indexOf(b.resultado.degrau) || a.posicao - b.posicao,
289 )
290 .slice(0, MAX_MELHORIAS)
291 .map(({ resultado }) => resultado)
292}
293
294export function avaliacaoPronta(id: string, resultados: readonly Resultado[], ms: number): AvaliacaoPronta {
295 return {
296 estado: 'ok',
297 id,
298 nota: notaDoTurno(resultados),
299 ms,
300 resultados: [...resultados],
301 melhorias: pontosDeMelhoria(resultados),
302 }
303}
304
305export const acrescentarNota = (historico: readonly number[], nota: number): number[] =>
306 [...historico, nota].slice(-MAX_HISTORICO)
307
308/** O que veio do $.store como histórico: só notas de 0 a 10, as últimas 20. */
309export function notasValidas(valor: unknown): number[] {
310 if (!Array.isArray(valor)) return []
311 const notas = valor.filter(
312 (nota): nota is number => typeof nota === 'number' && Number.isFinite(nota) && nota >= 0 && nota <= NOTA_MAXIMA,
313 )
314 return notas.slice(-MAX_HISTORICO)
315}
316
317// ── Formatação ──────────────────────────────────────────────────────────────
318
319export const formatarNota = (nota: number): string => nota.toFixed(1).replace('.', ',')
320
321export const formatarConfianca = (confianca: number): string => confianca.toFixed(2).replace('.', ',')
322
323export function barraDeConfianca(confianca: number): string {
324 const cheias = Math.round(confianca * LARGURA_DA_BARRA)
325 return '█'.repeat(cheias) + '░'.repeat(LARGURA_DA_BARRA - cheias)
326}
327
328/** Uma barrinha ▁..█ por nota: nota/10*7 vira a altura. */
329export function miniGrafico(notas: readonly number[]): string {
330 const topo = ESCALA_DO_GRAFICO.length - 1
331 return notas
332 .map(nota => {
333 const altura = Math.min(topo, Math.max(0, Math.round((nota / NOTA_MAXIMA) * topo)))
334 return ESCALA_DO_GRAFICO.charAt(altura)
335 })
336 .join('')
337}
338
339export const ultimasNotas = (historico: readonly number[]): string =>
340 historico.slice(-MAX_NOTAS_NO_TEXTO).map(formatarNota).join(SEPARADOR_DE_NOTAS)
341
342// ── Textos do boletim ───────────────────────────────────────────────────────
343
344/** A linha que vai para a conversa quando a avaliação termina. */
345export function linhaDeConversa(avaliacao: Avaliacao): string {
346 if (avaliacao.estado === 'avaliando') return 'Boletim: Jev avaliando o turno…'
347 if (avaliacao.estado === 'fora') return 'Boletim: Jev fora do ar, turno sem nota'
348 const nota = formatarNota(avaliacao.nota)
349 if (avaliacao.melhorias.length === 0) return `Boletim: nota ${nota} · nenhum ponto de melhoria`
350 const lista = avaliacao.melhorias.map(item => `${item.nome} (${item.degrau})`).join(', ')
351 return `Boletim: nota ${nota} · melhorar: ${lista}`
352}
353
354/** A linha fixa acima do prompt, com o tom em que ela é desenhada. */
355export function linhaAcimaDoPrompt(avaliacao: Avaliacao): { texto: string; tom: TomDaLinha } {
356 if (avaliacao.estado === 'avaliando') return { texto: 'Boletim: Jev avaliando o turno…', tom: 'discreto' }
357 if (avaliacao.estado === 'fora') return { texto: 'Boletim: Jev fora do ar', tom: 'aviso' }
358 const nota = formatarNota(avaliacao.nota)
359 const quantos = avaliacao.melhorias.length
360 if (quantos === 0) {
361 return { texto: `Boletim: nota ${nota} · nenhum ponto de melhoria · /boletim pra ver`, tom: 'discreto' }
362 }
363 const pontos = quantos === 1 ? '1 ponto de melhoria' : `${quantos} pontos de melhoria`
364 return {
365 texto: `Boletim: nota ${nota} · ${pontos} · /boletim pra ver, /melhorar pra gravar no CLAUDE.md`,
366 tom: 'normal',
367 }
368}
369
370function linhaDoCriterio(resultado: Resultado, largura: number): Linha {
371 const cor = resultado.confianca < CONFIANCA_BAIXA ? 'yellow' : COR_DO_DEGRAU[resultado.degrau]
372 const folga = ' '.repeat(LARGURA_DO_DEGRAU - resultado.degrau.length)
373 return [
374 { texto: `${resultado.nome.padEnd(largura)} ` },
375 { texto: resultado.degrau, negrito: true, ...(cor === undefined ? {} : { cor }) },
376 { texto: `${folga} ${barraDeConfianca(resultado.confianca)} ${formatarConfianca(resultado.confianca)}` },
377 ]
378}
379
380function secaoDeMelhorias(melhorias: readonly Resultado[]): Linha[] {
381 if (melhorias.length === 0) {
382 return [[{ texto: 'Nenhum ponto de melhoria neste turno.', cor: 'green' }]]
383 }
384 return [
385 [{ texto: 'Pontos de melhoria', negrito: true }],
386 ...melhorias.flatMap((item): Linha[] => [
387 [{ texto: `${item.nome} (${item.degrau})` }],
388 [{ texto: ` Regra sugerida: ${item.regra}` }],
389 ]),
390 [{ texto: '/melhorar grava essas regras no CLAUDE.md', discreto: true }],
391 ]
392}
393
394function corpoDoBoletim(avaliacao: Avaliacao | null): Linha[] {
395 if (avaliacao === null) {
396 return [[{ texto: 'Ainda não tem boletim. Ele aparece quando o turno terminar.', discreto: true }]]
397 }
398 if (avaliacao.estado === 'avaliando') return [[{ texto: 'Jev avaliando o turno…', discreto: true }]]
399 if (avaliacao.estado === 'fora') {
400 return [[{ texto: 'Jev fora do ar: o último turno ficou sem nota.', cor: 'yellow' }]]
401 }
402 const largura = Math.max(...avaliacao.resultados.map(resultado => resultado.nome.length))
403 return [
404 [{ texto: `Nota do último turno: ${formatarNota(avaliacao.nota)} · Jev respondeu em ${avaliacao.ms} ms` }],
405 LINHA_EM_BRANCO,
406 ...avaliacao.resultados.map(resultado => linhaDoCriterio(resultado, largura)),
407 LINHA_EM_BRANCO,
408 ...secaoDeMelhorias(avaliacao.melhorias),
409 ]
410}
411
412function secaoDoHistorico(historico: readonly number[]): Linha[] {
413 if (historico.length === 0) return []
414 return [
415 LINHA_EM_BRANCO,
416 [{ texto: 'Últimos turnos', negrito: true }],
417 [{ texto: miniGrafico(historico) }],
418 [{ texto: ultimasNotas(historico) }],
419 ]
420}
421
422/** O boletim inteiro, linha por linha, igual no painel e na faixa acima do prompt. */
423export function montarBoletim(avaliacao: Avaliacao | null, historico: readonly number[]): Linha[] {
424 return [...corpoDoBoletim(avaliacao), ...secaoDoHistorico(historico)]
425}
426
427// ── Retrato do turno ────────────────────────────────────────────────────────
428
429export function iniciarTurno(anterior: Turno, texto: string): Turno {
430 return { pedido: texto.trim() === '' ? anterior.pedido : texto, arquivos: [], comandos: [] }
431}
432
433/** O que uma chamada de ferramenta acrescenta ao retrato, ou null se não interessa. */
434export function registroDaFerramenta(
435 ferramenta: string,
436 entrada: Readonly<Record<string, unknown>>,
437 ok: boolean,
438): Registro | null {
439 if (FERRAMENTAS_DE_ARQUIVO.has(ferramenta)) {
440 const caminho = entrada.file_path ?? entrada.notebook_path
441 return ok && ehTexto(caminho) ? { arquivo: caminho } : null
442 }
443 if (!FERRAMENTAS_DE_SHELL.has(ferramenta)) return null
444 const comando = entrada.command
445 if (!ehTexto(comando)) return null
446 return { comando: { comando: comando.slice(0, MAX_COMANDO), ok, teste: PADRAO_DE_TESTE.test(comando) } }
447}
448
449export function aplicarRegistro(turno: Turno, registro: Registro): Turno {
450 if ('comando' in registro) {
451 return { ...turno, comandos: [...turno.comandos, registro.comando].slice(-MAX_GUARDADOS) }
452 }
453 if (turno.arquivos.includes(registro.arquivo)) return turno
454 return { ...turno, arquivos: [...turno.arquivos, registro.arquivo].slice(-MAX_GUARDADOS) }
455}
456
457// ── /melhorar: regras no CLAUDE.md ──────────────────────────────────────────
458
459/** Espaços viram um só e maiúsculas somem: é assim que duas regras são comparadas. */
460export const normalizar = (texto: string): string => texto.replace(/\s+/g, ' ').trim().toLowerCase()
461
462const ehTituloDeSecao = (linha: string): boolean => /^#{1,2}\s/.test(linha)
463
464function novaSecao(conteudo: string, regras: readonly string[], usaCrlf: boolean): string {
465 const quebra = usaCrlf ? '\r\n' : '\n'
466 const bloco = [TITULO_DA_SECAO, '', ...regras.map(regra => `- ${regra}`)].join(quebra) + quebra
467 const existente = conteudo.trimEnd()
468 return existente === '' ? bloco : `${existente}${quebra}${quebra}${bloco}`
469}
470
471function inserirNaSecao(linhas: readonly string[], cabecalho: number, regras: readonly string[], fim: string): string {
472 const proxima = linhas.findIndex((linha, posicao) => posicao > cabecalho && ehTituloDeSecao(linha))
473 const limite = proxima === -1 ? linhas.length : proxima
474 const corpo = linhas.slice(cabecalho + 1, limite)
475 const ultimo = corpo.findLastIndex(linha => linha.trim() !== '')
476 const marcadores = regras.map(regra => `- ${regra}${fim}`)
477
478 if (ultimo === -1) {
479 const separador = limite === cabecalho + 1 && limite < linhas.length ? [fim] : []
480 return [
481 ...linhas.slice(0, cabecalho + 1),
482 fim,
483 ...marcadores,
484 ...separador,
485 ...linhas.slice(cabecalho + 1),
486 ].join('\n')
487 }
488 const depois = cabecalho + 1 + ultimo + 1
489 if (depois === linhas.length) {
490 // A seção é a última coisa do arquivo e ele não termina em quebra de linha: ganha uma.
491 return [...linhas.slice(0, depois - 1), `${linhas[depois - 1]}${fim}`, ...marcadores, ''].join('\n')
492 }
493 return [...linhas.slice(0, depois), ...marcadores, ...linhas.slice(depois)].join('\n')
494}
495
496/**
497 * Põe as regras que ainda não estão no CLAUDE.md na seção "## Regras do boletim",
498 * no fim dela. Cria a seção (e o arquivo, se `conteudo` for vazio) quando falta e
499 * mantém CRLF se o arquivo usa CRLF.
500 */
501export function incluirRegras(conteudo: string, regras: readonly string[]): RegrasIncluidas {
502 const existente = normalizar(conteudo)
503 const unicas = regras
504 .map(regra => regra.trim())
505 .filter((regra, posicao, todas) => {
506 const chave = normalizar(regra)
507 return chave !== '' && todas.findIndex(outra => normalizar(outra) === chave) === posicao
508 })
509 const jaExistiam = unicas.filter(regra => existente.includes(normalizar(regra)))
510 const adicionadas = unicas.filter(regra => !existente.includes(normalizar(regra)))
511 if (adicionadas.length === 0) return { conteudo, adicionadas, jaExistiam }
512
513 const usaCrlf = conteudo.includes('\r\n')
514 const linhas = conteudo.split('\n')
515 const cabecalho = linhas.findIndex(linha => normalizar(linha) === normalizar(TITULO_DA_SECAO))
516 const novo =
517 cabecalho === -1
518 ? novaSecao(conteudo, adicionadas, usaCrlf)
519 : inserirNaSecao(linhas, cabecalho, adicionadas, usaCrlf ? '\r' : '')
520 return { conteudo: novo, adicionadas, jaExistiam }
521}
522
523const AVISO_SEM_BOLETIM = 'Ainda não tem boletim de um turno pra usar.'
524
525/** Decide o que o /melhorar faz com a avaliação do último turno. */
526export function planejarMelhorar(avaliacao: Avaliacao | null): Plano {
527 if (avaliacao === null || avaliacao.estado === 'avaliando') {
528 return { tipo: 'aviso', texto: AVISO_SEM_BOLETIM }
529 }
530 if (avaliacao.estado === 'fora') {
531 return {
532 tipo: 'aviso',
533 texto: 'O último turno ficou sem boletim (Jev fora do ar). O CLAUDE.md ficou como estava.',
534 }
535 }
536 if (avaliacao.melhorias.length === 0) {
537 return {
538 tipo: 'aviso',
539 texto: `Nenhum ponto de melhoria no último turno (nota ${formatarNota(avaliacao.nota)}). O CLAUDE.md ficou como estava.`,
540 }
541 }
542 return { tipo: 'regras', regras: avaliacao.melhorias.map(item => item.regra) }
543}
544
545function sobraDoCLAUDE(jaExistiam: readonly string[]): string {
546 if (jaExistiam.length === 0) return ''
547 if (jaExistiam.length === 1) return '\nA outra já estava no CLAUDE.md.'
548 return `\nAs outras ${jaExistiam.length} já estavam no CLAUDE.md.`
549}
550
551export function textoRegrasGravadas(adicionadas: readonly string[], jaExistiam: readonly string[]): string {
552 const sobra = sobraDoCLAUDE(jaExistiam)
553 const [unica] = adicionadas
554 if (adicionadas.length === 1 && unica !== undefined) {
555 return `Gravei no CLAUDE.md esta regra: ${unica}${sobra}`
556 }
557 const lista = adicionadas.map(regra => `- ${regra}`).join('\n')
558 return `Gravei no CLAUDE.md estas ${adicionadas.length} regras:\n${lista}${sobra}`
559}
560
561export function textoRegrasJaExistem(regras: readonly string[]): string {
562 const citadas = regras.map(regra => `"${regra}"`).join(', ')
563 return regras.length === 1
564 ? `A regra ${citadas} já está no CLAUDE.md. Nada mudou.`
565 : `As regras ${citadas} já estão no CLAUDE.md. Nada mudou.`
566}
567types/index.d.ts 59 lines1/** Os três degraus de cada critério, do pior para o melhor. */
2export type Degrau = 'fraco' | 'ok' | 'ótimo'
3
4/** Um comando de shell que o turno rodou. */
5export type Comando = {
6 comando: string
7 /** Não teve deny e não deu erro. */
8 ok: boolean
9 /** Parece teste, tipagem, lint ou build. */
10 teste: boolean
11}
12
13/** O que o turno em andamento já fez: o pedido, os arquivos mexidos e os comandos rodados. */
14export type Turno = {
15 pedido: string
16 arquivos: string[]
17 comandos: Comando[]
18}
19
20/** A nota de um critério no turno avaliado. */
21export type Resultado = {
22 nome: string
23 degrau: Degrau
24 /** De 0 a 1, como o Jev devolve. */
25 confianca: number
26 /** A regra do critério, a que o /melhorar grava no CLAUDE.md. */
27 regra: string
28}
29
30/** O boletim de um turno: avaliando, sem nota (Jev fora) ou pronto. */
31export type Avaliacao =
32 | { estado: 'avaliando'; id: string }
33 | { estado: 'fora'; id: string }
34 | {
35 estado: 'ok'
36 id: string
37 /** Média do turno, com uma casa decimal. */
38 nota: number
39 /** Quanto o Jev demorou para responder. */
40 ms: number
41 resultados: Resultado[]
42 /** Até 2 critérios abaixo de ótimo, do mais baixo para o mais alto. */
43 melhorias: Resultado[]
44 }
45
46/** Onde o boletim aparece agora: painel ao lado, faixa acima do prompt ou em lugar nenhum. */
47export type Onde = 'painel' | 'faixa' | null
48
49declare module 'claude-code' {
50 interface PluginState {
51 'boletim-do-claude': {
52 turno: Turno
53 avaliacao: Avaliacao | null
54 historico: number[]
55 onde: Onde
56 }
57 }
58}
59