SLOPSHOPPER

boletim-do-claude

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

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · boletim-do-claude
│ ┃ Boletim do Claude ✕ › fix the failing auth test and add an audit log call │ ┃ Jev avaliando o turno… │ ● boletim-do-claude: turno: undefined is not an object (evaluating 't │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /boletim │ ⎿ boletim-do-claude: Boletim do Claude aberto ao lado da conversa. │ │ ⟨Claude Code's own drawing⟩ Boletim: Jev avaliando o turno… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Boletim: Jev avaliando o turno…
Pane · Boletim do Claude
Jev avaliando o turno…
README

boletim-do-claude

🇺🇸 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.

O que ele faz

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érioPergunta
Resumo finalDisse o que mudou e em quais arquivos?
Como conferirDisse qual página abrir e o que clicar?
ProvaRodou testes ou abriu a página e disse o resultado?
SuposiçõesAvisou o que decidiu sozinho?
Tamanho da mudançaMexeu só no que o pedido exigia?
Próximo passoSugeriu 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:

  • uma linha na conversa, como Boletim: nota 7,3 · melhorar: Prova (fraco), Como conferir (ok);
  • uma linha acima do prompt;
  • o painel Boletim do Claude ao lado da conversa, com um quadro por critério e o gráfico dos últimos turnos;
  • quando não há espaço para o painel, o boletim inteiro numa caixa de borda ciano acima do prompt.
ComandoO que faz
/boletimAbre o painel com o boletim do último turno.
/melhorarGrava no CLAUDE.md da raiz as regras dos pontos de melhoria, na seção ## Regras do boletim, sem duplicar.

Requisitos

  • Claude Code com a API de mods. Testado na 2.1.295; essa API é de acesso antecipado e pode mudar entre versões.
  • Chave da TypeSafe em ~/.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.

Instalação

/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

Editando os critérios

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.

Privacidade e falhas

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).

Desenvolvimento

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.

CaminhoPapel
hooks/register.tsxTudo o que toca no motor: eventos, estado, arquivos, rede, tela e comandos.
hooks/jev.tsLógica pura: requisição ao Jev, notas, gráfico, textos do boletim e edição do CLAUDE.md.
types/index.d.tsContrato do $.state (turno, avaliação, histórico e onde o boletim aparece).
criterios.jsonCritérios editáveis, com os degraus de cada um.
tests/Motor falso (Jev, arquivos, store, relógio, painel) e os testes do plugin.
Source 3 files
hooks/register.tsx 313 lines
1// 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}
313
hooks/jev.ts 567 lines
1// 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}
567
types/index.d.ts 59 lines
1/** 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