brito-cli 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,9 +14,9 @@ Brito is a terminal agent for software development. Instead of grepping and read
14
14
 
15
15
  - **Graph-first exploration.** `graph_search`, `graph_context`, `graph_callers`, `graph_callees` and `graph_impact` answer "where is it, who uses it, what breaks" without reading whole files. The graph is embedded ([Kùzu](https://kuzudb.com)), local and incremental.
16
16
  - **Wide coverage.** TypeScript/JavaScript, Python and the script blocks of `.svelte`, `.vue` and `.astro` components. Import aliases (`tsconfig` paths, `package.json` imports, SvelteKit's `$lib`) become real edges. Files ignored by `.gitignore` stay out.
17
- - **Skills that learn, with a human in the loop.** After a complex task Brito reflects on what happened and *proposes* skills. Nothing becomes active until you run `/skills approve`.
17
+ - **Skills that learn on their own, on probation.** After a task that tripped or was corrected, Brito reflects and creates a skill *in trial* without interrupting you. A skill proves itself by use: 3 clean uses promote it, 2 stumbles archive it (recoverable). Anything risky, or any change to a skill you approved, still waits for your `/skills approve`. `BRITO_SKILLS=ask` restores approval-for-everything; `off` disables learning.
18
18
  - **Safety rails for what gets learned.** A lint rejects secrets, credentials embedded in URLs, `curl | sh` and prompt-injection phrasing, and **warns about destructive commands** (see below).
19
- - **Permission modes.** manual, auto-edits, plan and "everything allowed" (`Shift+Tab` cycles them). Even in the last one, skills are queued for review instead of being activated.
19
+ - **Permission modes.** manual, auto-edits, plan and "everything allowed" (`Shift+Tab` cycles them). Autonomous skills do not depend on the mode: they are created in trial and judged by results.
20
20
  - **Two backends, one interface.** Use an Anthropic API key, or your Claude Code subscription through the `claude` CLI. Brito never reads or sends your subscription token.
21
21
  - **Subagents.** `explore`, `plan` and `general` run in isolated contexts (read-only ones in parallel); add your own in `.brito/agents/*.md`.
22
22
  - **MCP server.** `brito mcp-serve` exposes the graph, memory and skills to other MCP clients such as Claude Code. The query tools are marked `alwaysLoad`, so they are not hidden behind tool search.
@@ -29,9 +29,13 @@ flowchart LR
29
29
  cg <-->|"graph_search · graph_callers<br/>graph_impact · graph_context"| agent{{"Brito agent"}}
30
30
  mem[("Memory<br/>decisions · conventions")] <-->|"recall / remember"| agent
31
31
  agent -->|"after a complex task"| learn["Reflection"]
32
- learn -->|"proposes"| pending["Pending skills"]
33
- pending -->|"/skills approve<br/>you decide"| skills[("Skills<br/>.brito/skills")]
32
+ learn -->|"creates"| trial["Skill in trial"]
33
+ trial -->|"3 clean uses"| skills[("Skills<br/>.brito/skills")]
34
+ trial -->|"2 stumbles or 21 idle days"| trash["Archived (.trash)"]
35
+ learn -->|"risky or changes yours"| pending["Pending<br/>/skills approve"]
36
+ pending --> skills
34
37
  skills -->|"skill_load"| agent
38
+ trial -->|"skill_load"| agent
35
39
  ```
36
40
 
37
41
  - **Code graph**: functions, classes, methods and components as nodes; `CALLS` and `IMPORTS` as edges. It re-parses only files whose hash changed and updates itself after edits.
@@ -39,11 +43,12 @@ flowchart LR
39
43
  - **Skills**: step-by-step procedures for *your* project (commands, order, traps, how to verify). The agent loads one with `skill_load` before a task it covers.
40
44
  - **Journal**: Brito remembers across sessions which tasks repeated, which is the best hint that a procedure is general and worth saving.
41
45
 
42
- ## Skills you review, not magic
46
+ ## Skills that earn their place
43
47
 
44
48
  ![Reviewing a pending skill, with a destructive-command warning](docs/images/skills-review.png)
45
49
 
46
- 1. A proposal appears in `/skills pending`, flagged when it contains destructive commands.
50
+ 0. **Autonomous path (default).** The reflection saves a new skill as *trial*, marked "(em teste)" in the index. Each turn that loads it counts as a win (no error-then-fix, no user correction) or a loss. 3 wins (and at most 1 loss per 3 wins) promote it; 2 losses without more wins, or 21 days unused, archive it to `.brito/skills/.trash`. Brito prints one line per promotion or archive. Limits: at most 3 new automatic skills a day, project scope only, and a weekly automatic consolidation that only touches automatic skills.
51
+ 1. **Review path.** Skills with destructive commands, and changes to skills you approved, appear in `/skills pending`, flagged when they contain destructive commands.
47
52
  2. `/skills approve <name>` shows the **whole body** and the lint warnings, then asks for confirmation. That confirmation is required even in "everything allowed" mode.
48
53
  3. Approved skills are versioned (`history/`), ranked by use, and flagged when the files they cite no longer exist. `/skills consolidate` merges duplicates and prunes unused ones.
49
54
  4. A skill can be project-scoped or **global** (valid in every project); `/skills promote` moves one to global.
@@ -52,7 +57,7 @@ The lint **rejects** secrets/tokens, absolute user paths, credentials in URLs (`
52
57
 
53
58
  ## Install
54
59
 
55
- Requires Node >= 20. Developed on macOS; Linux should work, Windows is untested.
60
+ Requires Node >= 20. Developed on macOS; Linux should work. On Windows use `npm i -g brito-cli` (the `install.sh` script needs bash); install Git for Windows so the `bash` tool runs bash commands, otherwise it falls back to `cmd.exe` (override with `BRITO_SHELL`).
56
61
 
57
62
  ```bash
58
63
  # from npm (the command is `brito`)
@@ -133,7 +138,7 @@ Brito has **no database drivers and connects to nothing on its own**. When it ne
133
138
 
134
139
  ## Configuration
135
140
 
136
- `BRITO_MODEL`, `BRITO_EFFORT`, `BRITO_EXPLORE_MODEL`, `BRITO_LEARN_MODEL`, `BRITO_HOME` (where global skills and history live), `BRITO_CONFIG`, `BRITO_COLOR`, `BRITO_BANNER` (`compact` or `full`; default: full screen on windows of 95+ columns and 28+ rows), `BRITO_INDEX_IGNORED`, `BRITO_SKILL_MIN_TOOLS` (how long a turn must be to trigger reflection when nothing else does).
141
+ `BRITO_MODEL`, `BRITO_EFFORT`, `BRITO_EXPLORE_MODEL`, `BRITO_LEARN_MODEL`, `BRITO_HOME` (where global skills and history live), `BRITO_CONFIG`, `BRITO_COLOR`, `BRITO_BANNER` (`compact` or `full`; default: full screen on windows of 95+ columns and 28+ rows), `BRITO_INDEX_IGNORED`, `BRITO_SKILL_MIN_TOOLS` (how long a turn must be to trigger reflection when nothing else does), `BRITO_SKILLS` (`auto` default, `ask`, `off`), `BRITO_SHELL` (shell used by the `bash` tool; on Windows defaults to Git Bash when installed).
137
142
 
138
143
  Project data lives in `.brito/` (graph, project skills, agents) and global data in `~/.brito`. The graph, memory and skills stay on your machine; as with any coding agent, the code the model reads is sent to the model provider you configured.
139
144
 
package/README.pt-BR.md CHANGED
@@ -14,9 +14,9 @@ O Brito é um agente de terminal para desenvolvimento de software. Em vez de dar
14
14
 
15
15
  - **Exploração guiada pelo grafo.** `graph_search`, `graph_context`, `graph_callers`, `graph_callees` e `graph_impact` respondem "onde está, quem usa, o que quebra" sem ler arquivos inteiros. O grafo é embutido ([Kùzu](https://kuzudb.com)), local e incremental.
16
16
  - **Cobertura ampla.** TypeScript/JavaScript, Python e os blocos de script de componentes `.svelte`, `.vue` e `.astro`. Aliases de import (`paths` do tsconfig, `imports` do package.json, `$lib` do SvelteKit) viram arestas de verdade. O que o `.gitignore` ignora fica de fora.
17
- - **Skills que aprendem, com um humano no circuito.** Depois de uma tarefa complexa o Brito reflete sobre o que aconteceu e *propõe* skills. Nada fica ativo até você rodar `/skills approve`.
17
+ - **Skills que aprendem sozinhas, em período de teste.** Depois de uma tarefa em que algo tropeçou ou foi corrigido, o Brito reflete e cria uma skill *em teste* sem te interromper. A skill se prova pelo uso: 3 usos limpos a promovem, 2 tropeços a arquivam (recuperável). O que é arriscado, ou qualquer mudança numa skill que você aprovou, continua esperando o seu `/skills approve`. `BRITO_SKILLS=ask` volta a pedir aprovação para tudo; `off` desliga o aprendizado.
18
18
  - **Proteções sobre o que é aprendido.** Um lint rejeita segredos, credenciais embutidas em URL, `curl | sh` e frases de injeção de prompt, e **avisa sobre comandos destrutivos** (veja abaixo).
19
- - **Modos de permissão.** manual, auto-edições, plano e "tudo liberado" (`Shift+Tab` alterna). Mesmo no último, as skills vão para a fila de revisão em vez de ficarem ativas.
19
+ - **Modos de permissão.** manual, auto-edições, plano e "tudo liberado" (`Shift+Tab` alterna). As skills autônomas não dependem do modo: nascem em teste e são julgadas pelo resultado.
20
20
  - **Dois backends, uma interface.** Use uma chave da API da Anthropic ou a sua assinatura do Claude Code pela CLI `claude`. O Brito nunca lê nem envia o token da sua assinatura.
21
21
  - **Subagentes.** `explore`, `plan` e `general` rodam em contextos isolados (os somente-leitura, em paralelo); crie os seus em `.brito/agents/*.md`.
22
22
  - **Servidor MCP.** `brito mcp-serve` expõe grafo, memória e skills a outros clientes MCP, como o Claude Code. As tools de consulta são marcadas `alwaysLoad`, para não ficarem escondidas atrás da busca de tools.
@@ -39,11 +39,12 @@ flowchart LR
39
39
  - **Skills**: procedimentos passo a passo para o *seu* projeto (comandos, ordem, armadilhas, como verificar). O agente carrega uma com `skill_load` antes de uma tarefa que ela cobre.
40
40
  - **Journal**: o Brito lembra entre sessões quais tarefas se repetiram, o melhor indício de que um procedimento é geral e vale guardar.
41
41
 
42
- ## Skills que você revisa, sem mágica
42
+ ## Skills que merecem o lugar
43
43
 
44
44
  ![Revisando uma skill pendente, com aviso de comando destrutivo](docs/images/skills-review.png)
45
45
 
46
- 1. Uma proposta aparece em `/skills pending`, sinalizada quando contém comandos destrutivos.
46
+ 0. **Caminho autônomo (padrão).** A reflexão grava a skill nova como *em teste*, marcada "(em teste)" no índice. Cada turno que a carrega conta como vitória (sem erro→conserto nem correção do usuário) ou falha. 3 vitórias (e no máximo 1 falha a cada 3 vitórias) a promovem; 2 falhas sem mais vitórias, ou 21 dias sem uso, a arquivam em `.brito/skills/.trash`. O Brito imprime uma linha a cada promoção ou arquivamento. Limites: no máximo 3 skills automáticas novas por dia, só no escopo do projeto, e uma consolidação semanal automática que só mexe em skills automáticas.
47
+ 1. **Caminho de revisão.** Skills com comandos destrutivos, e mudanças em skills que você aprovou, aparecem em `/skills pending`, sinalizadas quando contêm comandos destrutivos.
47
48
  2. `/skills approve <nome>` mostra o **corpo inteiro** e os avisos do lint, e pede confirmação. Essa confirmação é exigida mesmo no modo "tudo liberado".
48
49
  3. Skills aprovadas são versionadas (`history/`), ranqueadas por uso e sinalizadas quando os arquivos que citam deixam de existir. `/skills consolidate` junta duplicadas e poda as sem uso.
49
50
  4. Uma skill pode ser do projeto ou **global** (vale em todos os projetos); `/skills promote` move uma para global.
@@ -133,7 +134,7 @@ O Brito **não tem drivers de banco e não se conecta a nada sozinho**. Quando p
133
134
 
134
135
  ## Configuração
135
136
 
136
- `BRITO_MODEL`, `BRITO_EFFORT`, `BRITO_EXPLORE_MODEL`, `BRITO_LEARN_MODEL`, `BRITO_HOME` (onde ficam skills globais e histórico), `BRITO_CONFIG`, `BRITO_COLOR`, `BRITO_BANNER` (`compact` ou `full`; padrão: tela cheia em janelas de 95+ colunas e 28+ linhas), `BRITO_INDEX_IGNORED`, `BRITO_SKILL_MIN_TOOLS` (tamanho mínimo do turno para disparar a reflexão quando nada mais dispara).
137
+ `BRITO_MODEL`, `BRITO_EFFORT`, `BRITO_EXPLORE_MODEL`, `BRITO_LEARN_MODEL`, `BRITO_HOME` (onde ficam skills globais e histórico), `BRITO_CONFIG`, `BRITO_COLOR`, `BRITO_BANNER` (`compact` ou `full`; padrão: tela cheia em janelas de 95+ colunas e 28+ linhas), `BRITO_INDEX_IGNORED`, `BRITO_SKILL_MIN_TOOLS` (tamanho mínimo do turno para disparar a reflexão quando nada mais dispara), `BRITO_SKILLS` (`auto` padrão, `ask`, `off`), `BRITO_SHELL` (shell da tool `bash`; no Windows usa o Git Bash se instalado).
137
138
 
138
139
  Os dados do projeto ficam em `.brito/` (grafo, skills do projeto, agentes) e os globais em `~/.brito`. O grafo, a memória e as skills ficam na sua máquina; como em qualquer agente de código, o código que o modelo lê é enviado ao provedor de modelo que você configurou.
139
140
 
@@ -7,10 +7,12 @@ import { MCP_NAME, MCP_PREFIX, APPROVE_TOOL } from "../mcp/server.js";
7
7
  import { ui } from "../ui/ui.js";
8
8
  import { MODE_ENV, perms } from "../permissions.js";
9
9
  import { wireEffort } from "../model.js";
10
+ import { claudeCommand } from "../util/platform.js";
10
11
  // Backend alternativo: delega ao `claude` oficial, que autentica com o /login do usuário.
11
12
  // O brito nunca lê nem envia o token da assinatura. Grafo, memória e skills chegam ao claude via MCP.
12
13
  export function hasClaudeCli() {
13
- return spawnSync("claude", ["--version"], { stdio: "ignore" }).status === 0;
14
+ const c = claudeCommand(["--version"]);
15
+ return spawnSync(c.command, c.args, { stdio: "ignore" }).status === 0;
14
16
  }
15
17
  /** Nome das tools do Brito → nome que o claude enxerga. */
16
18
  const CLAUDE_TOOL = { read_file: "Read", glob: "Glob", grep: "Grep", write_file: "Write", edit_file: "Edit", bash: "Bash" };
@@ -94,7 +96,10 @@ export class ClaudeSession {
94
96
  this.o = o;
95
97
  }
96
98
  reset() { this.id = randomUUID(); this.started = false; this.contextTokens = 0; this.recentTurns = []; }
97
- abort() { this.child?.kill("SIGINT"); }
99
+ abort() { if (!this.child)
100
+ return; this.aborted = true; this.child.kill("SIGINT"); }
101
+ /** No Windows `kill("SIGINT")` termina o processo sem sinal nem código 130: lembramos que fomos nós que interrompemos. */
102
+ aborted = false;
98
103
  args(text) {
99
104
  const { root, approvalSock, stats, mcpCommand } = this.o;
100
105
  const mcp = mcpConfig(mcpCommand, approvalSock, perms.mode);
@@ -127,7 +132,8 @@ export class ClaudeSession {
127
132
  send(text) {
128
133
  return new Promise((resolve) => {
129
134
  ui.turnStart();
130
- const child = (this.child = spawn("claude", this.args(text), {
135
+ const cmd = claudeCommand(this.args(text));
136
+ const child = (this.child = spawn(cmd.command, cmd.args, {
131
137
  cwd: this.o.root, stdio: ["ignore", "pipe", "pipe"],
132
138
  // `/reasoning none`: o claude desliga o raciocínio com MAX_THINKING_TOKENS=0 (não há `--effort none`)
133
139
  env: wireEffort(this.effort).thinking ? process.env : { ...process.env, MAX_THINKING_TOKENS: "0" },
@@ -197,7 +203,8 @@ export class ClaudeSession {
197
203
  child.on("error", (e) => { ok = false; ui.error(`não consegui executar o claude: ${e.message}`); });
198
204
  child.on("close", (code, signal) => {
199
205
  this.child = null;
200
- interrupted = signal === "SIGINT" || code === 130;
206
+ interrupted = signal === "SIGINT" || code === 130 || this.aborted;
207
+ this.aborted = false;
201
208
  if (code !== 0 && !interrupted) {
202
209
  ok = false;
203
210
  if (stderr.trim())
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { shellNote } from "../util/platform.js";
3
4
  /**
4
5
  * Frase sobre o grafo para o system prompt. "0 símbolos" sem contexto faz o modelo concluir que o grafo é inútil, mas o
5
6
  * projeto pode só estar vazio no início (e o grafo se atualiza sozinho a cada edição). Por isso: rótulo do momento da
@@ -37,7 +38,7 @@ export function buildSystemPrompt(root, graphStats, skills = "") {
37
38
 
38
39
  # Ambiente
39
40
  Diretório do projeto: ${root}
40
- Data: ${new Date().toISOString().slice(0, 10)}${skills ? `\n\n# Skills disponíveis (use skill_load)\n${skills}` : ""}${projectDoc}`;
41
+ Data: ${new Date().toISOString().slice(0, 10)}${shellNote()}${skills ? `\n\n# Skills disponíveis (use skill_load)\n${skills}` : ""}${projectDoc}`;
41
42
  }
42
43
  import { skillsIndex } from "../skills/store.js";
43
44
  export function buildSubagentPrompt(root, def) {
package/dist/cli.js CHANGED
@@ -20,14 +20,16 @@ import { hasClaudeCli, ClaudeSession } from "./agent/claude-cli.js";
20
20
  import { serveMcp } from "./mcp/server.js";
21
21
  import "./tools/skills.js";
22
22
  import { approvePending, lintSkill, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
23
- import { applyProposal, describeProposal, proposeConsolidation, pruneCandidates } from "./skills/consolidate.js";
24
- import { defaultComplete, reflect, shouldReflect } from "./skills/learn.js";
23
+ import { applyProposal, autoConsolidate, describeProposal, proposeConsolidation, pruneCandidates } from "./skills/consolidate.js";
24
+ import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "./skills/learn.js";
25
+ import { expireTrials, skillsMode } from "./skills/trial.js";
25
26
  import { claudeCliComplete } from "./skills/learn-cli.js";
26
27
  import { buildSystemPrompt, graphStatsLine } from "./agent/prompt.js";
27
28
  import { allTools } from "./tools/registry.js";
28
29
  import { ui, c, fmtTokens } from "./ui/ui.js";
29
30
  import { runRepl } from "./repl.js";
30
- const program = new Command().name("brito").description("CLI de agentes de desenvolvimento com grafo de conhecimento").enablePositionalOptions();
31
+ const pkgVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
32
+ const program = new Command().name("brito").description("CLI de agentes de desenvolvimento com grafo de conhecimento").version(pkgVersion, "-v, --version", "mostra a versão").enablePositionalOptions();
31
33
  const rootOf = (o) => path.resolve(o.dir ?? process.cwd());
32
34
  program
33
35
  .command("index")
@@ -78,7 +80,8 @@ async function plainAsk(q) {
78
80
  * processo do servidor MCP e consulta ESTE processo por um socket unix, onde está o terminal.
79
81
  */
80
82
  function startApprovalServer(io) {
81
- const sock = path.join(os.tmpdir(), `brito-approve-${process.pid}.sock`);
83
+ // No Windows um socket unix em %TEMP% não é aceito por net.listen: o endereço tem que ser um named pipe.
84
+ const sock = process.platform === "win32" ? `\\\\.\\pipe\\brito-approve-${process.pid}` : path.join(os.tmpdir(), `brito-approve-${process.pid}.sock`);
82
85
  try {
83
86
  fs.unlinkSync(sock);
84
87
  }
@@ -353,7 +356,7 @@ const skillsCommand = (root, io, llm) => ({
353
356
  const skills = listSkills(root);
354
357
  for (const s of skills) {
355
358
  const stale = staleRefs(root, s);
356
- ui.print(` ${c.soft(s.name)} ${c.muted(`(${s.scope}) v${s.version} · ${s.uses} usos · último uso ${s.lastUsed} · ${s.description}`)}${stale.length ? " " + c.warn(`⚠ refs obsoletas: ${stale.join(", ")}`) : ""}`);
359
+ ui.print(` ${c.soft(s.name)} ${c.muted(`(${s.scope}) v${s.version} · ${s.uses} usos${s.origin === "auto" ? ` · ${s.status === "trial" ? "em teste" : "automática"} ${s.wins}✓/${s.losses}✗` : ""} · último uso ${s.lastUsed} · ${s.description}`)}${stale.length ? " " + c.warn(`⚠ refs obsoletas: ${stale.join(", ")}`) : ""}`);
357
360
  }
358
361
  const prune = pruneCandidates(skills);
359
362
  if (prune.length)
@@ -367,6 +370,10 @@ function makeLearner(getTurns, ctx, complete, model) {
367
370
  return async (force = false) => {
368
371
  const turns = getTurns();
369
372
  const turn = turns[turns.length - 1] ?? null;
373
+ // O resultado do turno vira evidência para as skills usadas nele (promoção/arquivamento), antes de refletir.
374
+ if (!force)
375
+ for (const n of [...recordOutcomes(turn, ctx), ...(skillsMode() === "off" ? [] : expireTrials(ctx.root))])
376
+ ui.print(c.ok("✦ ") + n);
370
377
  if (!force && !shouldReflect(turn, ctx)) {
371
378
  ctx.loadedSkills?.clear();
372
379
  return;
@@ -384,6 +391,13 @@ function makeLearner(getTurns, ctx, complete, model) {
384
391
  ui.print(c.ok("✦ ") + n);
385
392
  if (!notes.length)
386
393
  ui.info("✦ nenhuma skill nova");
394
+ if (skillsMode() === "auto") {
395
+ try {
396
+ for (const n of await autoConsolidate(ctx.root, model, complete))
397
+ ui.print(c.ok("✦ ") + n);
398
+ }
399
+ catch { /* manutenção é opcional */ }
400
+ }
387
401
  }
388
402
  catch (e) {
389
403
  ui.turnEnd(false);
package/dist/repl.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { shellFor } from "./util/platform.js";
1
2
  import readline from "node:readline";
2
3
  import path from "node:path";
3
4
  import { exec } from "node:child_process";
@@ -81,7 +82,7 @@ export async function runRepl(b, o) {
81
82
  return r;
82
83
  };
83
84
  // ---------------------------------------------------------------- comandos
84
- const shell = (cmd) => new Promise((done) => exec(cmd, { cwd: o.root, maxBuffer: 10 * 1024 * 1024 }, (err, out, errOut) => {
85
+ const shell = (cmd) => new Promise((done) => exec(cmd, { cwd: o.root, shell: shellFor(), maxBuffer: 10 * 1024 * 1024 }, (err, out, errOut) => {
85
86
  ui.print((out + (errOut ? c.err(errOut) : "") + (err && !out && !errOut ? c.err(String(err.message)) : "")).replace(/\n$/, ""));
86
87
  done();
87
88
  }));
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { z } from "zod";
2
4
  import { toApiTools } from "../tools/registry.js";
3
5
  import { describeSave } from "./describe.js";
@@ -67,10 +69,35 @@ export function describeProposal(root, p, now = Date.now()) {
67
69
  return `REMOVER skill ${p.name}${s ? ` (${s.scope}, ${s.uses} usos, último uso há ${daysSince(s.lastUsed, now)} dias; vai para .trash)` : ""}\n motivo: ${p.reason}`;
68
70
  }
69
71
  /** Aplica direto no store (o usuário já aprovou; não passa pelo atalho de aprovação do modo bypass). */
70
- export function applyProposal(root, p) {
72
+ export function applyProposal(root, p, auto = false) {
71
73
  if (p.kind === "save") {
72
- const r = saveSkill(root, p.input, p.scope);
74
+ const r = saveSkill(root, p.input, p.scope, { auto });
73
75
  return r.created ? `skill ${p.input.name} criada (${r.scope}, v1)` : `skill ${p.input.name} atualizada (${r.scope}, v${r.version})`;
74
76
  }
75
77
  return deleteSkill(root, p.name) ? `skill ${p.name} removida` : `skill ${p.name} não encontrada`;
76
78
  }
79
+ /** Só propostas que tocam skills criadas sozinhas podem ser aplicadas sem o usuário; as demais ficam para `/skills consolidate`. */
80
+ export function autoApplicable(root, proposals) {
81
+ return proposals.filter((p) => {
82
+ const s = getSkill(root, p.kind === "save" ? p.input.name : p.name);
83
+ return p.kind === "save" ? (!s || s.origin === "auto") : s?.origin === "auto";
84
+ });
85
+ }
86
+ const STAMP = ".last-consolidate";
87
+ const CONSOLIDATE_EVERY_DAYS = 7;
88
+ const CONSOLIDATE_MIN_SKILLS = 6;
89
+ /** Consolidação periódica sem o usuário: no máximo 1x por semana, com ≥6 skills, só sobre skills automáticas. Devolve avisos. */
90
+ export async function autoConsolidate(root, model, complete, now = Date.now()) {
91
+ if (listSkills(root, now).length < CONSOLIDATE_MIN_SKILLS)
92
+ return [];
93
+ const stamp = path.join(root, ".brito", "skills", STAMP);
94
+ try {
95
+ if (now - fs.statSync(stamp).mtimeMs < CONSOLIDATE_EVERY_DAYS * 86_400_000)
96
+ return [];
97
+ }
98
+ catch { /* nunca rodou */ }
99
+ fs.mkdirSync(path.dirname(stamp), { recursive: true });
100
+ fs.writeFileSync(stamp, new Date(now).toISOString());
101
+ const proposals = autoApplicable(root, await proposeConsolidation(root, model, complete, now));
102
+ return proposals.map((p) => applyProposal(root, p, true));
103
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { claudeCommand } from "../util/platform.js";
2
3
  import os from "node:os";
3
4
  // Backend sem ANTHROPIC_API_KEY: usa o `claude` oficial (login do usuário). Sem tools nem settings: só julga o transcript.
4
5
  // O `skill_save` vira saída estruturada ({saves:[…]}) e é convertida de volta em blocos tool_use.
@@ -30,7 +31,8 @@ export const claudeCliComplete = ({ model, system, user }) => new Promise((resol
30
31
  "--disable-slash-commands", "--setting-sources", "", "--json-schema", JSON.stringify(SCHEMA)];
31
32
  if (model)
32
33
  args.push("--model", model);
33
- const child = spawn("claude", args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
34
+ const cmd = claudeCommand(args);
35
+ const child = spawn(cmd.command, cmd.args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
34
36
  let out = "", err = "";
35
37
  child.stdout.on("data", (d) => (out += d));
36
38
  child.stderr.on("data", (d) => (err += d));
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { execTool, toApiTools } from "../tools/registry.js";
4
4
  import { perms } from "../permissions.js";
5
5
  import { getSkill, listPending, listSkills, savePending, staleRefs } from "./store.js";
6
+ import { saveAuto, skillsMode, recordOutcome } from "./trial.js";
6
7
  import { appendJournal, describeEntry, entryFromTurn, similarEntries } from "./journal.js";
7
8
  /** Turno longo sem nenhum outro sinal só vale reflexão a partir deste tamanho. */
8
9
  const MIN_TOOLS = Number(process.env.BRITO_SKILL_MIN_TOOLS ?? 12);
@@ -76,12 +77,27 @@ export function extractSignals(turn) {
76
77
  * `BRITO_LEARN_V2=0` volta ao gatilho antigo (≥6 tools ou skill usada).
77
78
  */
78
79
  export function shouldReflect(turn, ctx) {
79
- if (!turn || !turn.finished)
80
+ if (!turn || !turn.finished || skillsMode() === "off")
80
81
  return false;
81
82
  if (process.env.BRITO_LEARN_V2 === "0")
82
83
  return turn.toolCalls >= 6 || (ctx.loadedSkills?.size ?? 0) > 0;
83
84
  return extractSignals(turn).length > 0 || turn.toolCalls >= MIN_TOOLS;
84
85
  }
86
+ /** Resultado do turno para as skills carregadas nele: tropeço (erro→conserto ou correção do usuário) é falha. Abortado = neutro. */
87
+ export function judgeTurn(turn) {
88
+ if (!turn.finished)
89
+ return null;
90
+ return extractSignals(turn).length ? "loss" : "win";
91
+ }
92
+ /** Credita o turno às skills carregadas nele; pode promover ou arquivar skills em teste. Devolve avisos. */
93
+ export function recordOutcomes(turn, ctx) {
94
+ if (!turn || skillsMode() === "off")
95
+ return [];
96
+ const verdict = judgeTurn(turn);
97
+ if (!verdict)
98
+ return [];
99
+ return [...(ctx.loadedSkills ?? [])].flatMap((n) => recordOutcome(ctx.root, n, verdict === "win") ?? []);
100
+ }
85
101
  export const defaultComplete = async ({ model, system, user, tools, maxTokens }) => {
86
102
  const res = await new Anthropic().messages.create({ model, max_tokens: maxTokens, system, tools, messages: [{ role: "user", content: user }] });
87
103
  return res.content;
@@ -172,8 +188,21 @@ export function buildReflectInput(turn, ctx, previous) {
172
188
  (previous ? `# Turno anterior (só contexto, não é o que está sendo avaliado)\n${renderPrevious(previous)}\n\n` : "") +
173
189
  `# Transcript do turno (${turn.toolCalls} chamadas de tool, ${turn.errors} com erro)\n${renderTranscript(turn)}`);
174
190
  }
191
+ const SaveArgs = z.object({ name: z.string(), description: z.string(), body: z.string(), refs: z.array(z.string()).default([]), reason: z.string().default(""), scope: z.enum(["project", "global"]).optional() });
192
+ /** Modo autônomo: grava em teste (ou na fila de revisão, se for arriscado) sem perguntar ao usuário. */
193
+ function stageAuto(input, ctx) {
194
+ const parsed = SaveArgs.safeParse(input);
195
+ if (!parsed.success)
196
+ return { saved: false, msg: "skill não salva: input inválido" };
197
+ try {
198
+ return saveAuto(ctx.root, parsed.data);
199
+ }
200
+ catch (e) {
201
+ return { saved: false, msg: `skill não salva: ${e?.message ?? e}` };
202
+ }
203
+ }
175
204
  function stage(input, ctx) {
176
- const parsed = z.object({ name: z.string(), description: z.string(), body: z.string(), refs: z.array(z.string()).default([]), reason: z.string().default(""), scope: z.enum(["project", "global"]).optional() }).safeParse(input);
205
+ const parsed = SaveArgs.safeParse(input);
177
206
  if (!parsed.success)
178
207
  return `skill não salva: input inválido`;
179
208
  try {
@@ -195,6 +224,13 @@ export async function reflect(turn, ctx, model, complete = defaultComplete, prev
195
224
  continue;
196
225
  saves++;
197
226
  const name = String(b.input?.name ?? "");
227
+ if (skillsMode() === "auto") {
228
+ const r = stageAuto(b.input, ctx);
229
+ out.push(r.msg);
230
+ if (r.saved)
231
+ saved.push(name);
232
+ continue;
233
+ }
198
234
  // Em bypass nada pede aprovação: a proposta vai para a fila de revisão em vez de virar instrução ativa.
199
235
  if (perms.mode === "bypass") {
200
236
  const msg = stage(b.input, ctx);
@@ -22,14 +22,18 @@ function parse(text, scope) {
22
22
  updated: meta.updated ?? today(),
23
23
  lastUsed: meta.lastUsed ?? meta.updated ?? today(),
24
24
  refs: (meta.refs ?? "").split(",").map((r) => r.trim()).filter(Boolean),
25
+ status: meta.status === "trial" ? "trial" : "active",
26
+ origin: meta.origin === "auto" ? "auto" : "manual",
27
+ wins: Number(meta.wins) || 0,
28
+ losses: Number(meta.losses) || 0,
25
29
  body: body.trim(),
26
30
  scope,
27
31
  };
28
32
  }
29
- function write(root, s) {
33
+ export function writeSkill(root, s) {
30
34
  const file = fileOf(root, s.scope, s.name);
31
35
  fs.mkdirSync(path.dirname(file), { recursive: true });
32
- fs.writeFileSync(file, stringifyFrontmatter({ name: s.name, description: s.description, version: s.version, uses: s.uses, created: s.created, updated: s.updated, lastUsed: s.lastUsed, refs: s.refs.join(", ") }, s.body));
36
+ fs.writeFileSync(file, stringifyFrontmatter({ name: s.name, description: s.description, version: s.version, uses: s.uses, created: s.created, updated: s.updated, lastUsed: s.lastUsed, refs: s.refs.join(", "), status: s.status, origin: s.origin, wins: s.wins, losses: s.losses }, s.body));
33
37
  }
34
38
  /** Lê uma skill de um escopo específico. */
35
39
  export function getSkillIn(root, name, scope) {
@@ -131,8 +135,12 @@ export function validate(input) {
131
135
  export function targetScope(root, name, scope) {
132
136
  return scope ?? getSkill(root, name)?.scope ?? "project";
133
137
  }
134
- /** Cria ou atualiza. A versão anterior vai para `history/vN.md`. */
135
- export function saveSkill(root, input, scope) {
138
+ /**
139
+ * Cria ou atualiza. A versão anterior vai para `history/vN.md`.
140
+ * `opts.auto` = gravação sem revisão humana (reflexão autônoma): skill nova nasce em teste; atualizar mantém a origem, e
141
+ * uma skill em teste zera o placar (conteúdo novo = hipótese nova). Sem `auto`, um humano aprovou: vira ativa e manual.
142
+ */
143
+ export function saveSkill(root, input, scope, opts = {}) {
136
144
  validate(input);
137
145
  const target = targetScope(root, input.name, scope);
138
146
  const prev = getSkillIn(root, input.name, target);
@@ -150,10 +158,14 @@ export function saveSkill(root, input, scope) {
150
158
  updated: today(),
151
159
  lastUsed: prev?.lastUsed ?? today(),
152
160
  refs: input.refs ?? prev?.refs ?? [],
161
+ status: opts.auto ? (prev?.status ?? "trial") : "active",
162
+ origin: opts.auto ? (prev?.origin ?? "auto") : "manual",
163
+ wins: opts.auto && prev?.status === "trial" ? 0 : prev?.wins ?? 0,
164
+ losses: opts.auto && prev?.status === "trial" ? 0 : prev?.losses ?? 0,
153
165
  body: input.body.trim(),
154
166
  scope: target,
155
167
  };
156
- write(root, skill);
168
+ writeSkill(root, skill);
157
169
  return { created: !prev, version: skill.version, scope: target };
158
170
  }
159
171
  /** Propostas aguardando revisão (modo bypass): ficam fora do índice, pois `listScope` ignora diretórios com ponto. */
@@ -204,7 +216,7 @@ export function rejectPending(root, name) {
204
216
  export function bumpUses(root, name) {
205
217
  const s = getSkill(root, name);
206
218
  if (s)
207
- write(root, { ...s, uses: s.uses + 1, lastUsed: today() });
219
+ writeSkill(root, { ...s, uses: s.uses + 1, lastUsed: today() });
208
220
  }
209
221
  /** Move para `.trash` do próprio escopo em vez de apagar. */
210
222
  export function deleteSkill(root, name, scope) {
@@ -230,5 +242,5 @@ export function promoteSkill(root, name) {
230
242
  /** Índice compacto para o system prompt (divulgação progressiva: o corpo só entra via skill_load). */
231
243
  export function skillsIndex(root, max = 40, now = Date.now()) {
232
244
  const all = listSkills(root, now).slice(0, max);
233
- return all.length ? all.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${staleRefs(root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n") : "";
245
+ return all.length ? all.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${s.status === "trial" ? " (em teste)" : ""}${staleRefs(root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n") : "";
234
246
  }
@@ -0,0 +1,71 @@
1
+ import { daysSince, deleteSkill, getSkill, lintSkill, listSkills, savePending, saveSkill, writeSkill } from "./store.js";
2
+ export const skillsMode = () => (process.env.BRITO_SKILLS === "ask" || process.env.BRITO_SKILLS === "off" ? process.env.BRITO_SKILLS : "auto");
3
+ /** Usos bem-sucedidos para promover (com no máximo 1 tropeço a cada 3 sucessos). */
4
+ export const PROMOTE_WINS = 3;
5
+ /** Falhas que arquivam uma skill em teste sem mais vitórias que falhas. */
6
+ export const ARCHIVE_LOSSES = 2;
7
+ /** Skill em teste que ninguém carregou neste prazo é arquivada. */
8
+ export const TRIAL_DAYS = 21;
9
+ /** Teto de skills novas criadas sozinhas por dia (contido: cada uma vira instrução para o agente). */
10
+ export const DAILY_LIMIT = 3;
11
+ /** Registra o resultado de um turno que usou a skill. Devolve um aviso se ela foi promovida ou arquivada. */
12
+ export function recordOutcome(root, name, win) {
13
+ const s = getSkill(root, name);
14
+ if (!s)
15
+ return null;
16
+ const next = { ...s, wins: s.wins + (win ? 1 : 0), losses: s.losses + (win ? 0 : 1) };
17
+ if (s.origin === "auto") {
18
+ if (s.status === "trial") {
19
+ if (next.wins >= PROMOTE_WINS && next.wins >= 3 * next.losses) {
20
+ writeSkill(root, { ...next, status: "active" });
21
+ return `skill ${name} promovida: ${next.wins} usos bem-sucedidos, ${next.losses} com tropeço`;
22
+ }
23
+ if (next.losses >= ARCHIVE_LOSSES && next.losses >= next.wins) {
24
+ deleteSkill(root, name, s.scope);
25
+ return `skill ${name} arquivada: ${next.losses} usos com tropeço e ${next.wins} bem-sucedidos (recupere em .brito/skills/.trash)`;
26
+ }
27
+ }
28
+ else if (next.losses >= 3 && next.losses > next.wins) {
29
+ deleteSkill(root, name, s.scope);
30
+ return `skill ${name} arquivada: passou a falhar (${next.losses} tropeços, ${next.wins} sucessos)`;
31
+ }
32
+ }
33
+ writeSkill(root, next);
34
+ return null;
35
+ }
36
+ /** Arquiva as skills em teste que ninguém usou no prazo. */
37
+ export function expireTrials(root, now = Date.now()) {
38
+ const out = [];
39
+ for (const s of listSkills(root, now)) {
40
+ if (s.origin === "auto" && s.status === "trial" && daysSince(s.lastUsed, now) > TRIAL_DAYS && deleteSkill(root, s.name, s.scope)) {
41
+ out.push(`skill ${s.name} arquivada: em teste há mais de ${TRIAL_DAYS} dias sem uso`);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Grava uma skill proposta pela reflexão sem perguntar ao usuário, mas só quando é seguro:
48
+ * - sempre escopo do projeto (global exige decisão humana);
49
+ * - skill de origem manual nunca é reescrita sozinha: a mudança vira proposta pendente;
50
+ * - conteúdo com comando destrutivo também vai para a fila de revisão;
51
+ * - nova skill respeita o teto diário.
52
+ */
53
+ export function saveAuto(root, input, now = Date.now()) {
54
+ const prev = getSkill(root, input.name);
55
+ if (prev && prev.origin === "manual") {
56
+ const scope = savePending(root, input, prev.scope);
57
+ return { saved: false, msg: `skill ${input.name} (${scope}) é revisada por você: mudança proposta em /skills approve ${input.name}` };
58
+ }
59
+ if (lintSkill(input).warnings.some((w) => w.startsWith("comando destrutivo"))) {
60
+ const scope = savePending(root, input, "project");
61
+ return { saved: false, msg: `skill ${input.name} (${scope}) tem comando destrutivo: aguardando /skills approve ${input.name}` };
62
+ }
63
+ if (!prev) {
64
+ const today = new Date(now).toISOString().slice(0, 10);
65
+ const created = listSkills(root, now).filter((s) => s.origin === "auto" && s.created === today).length;
66
+ if (created >= DAILY_LIMIT)
67
+ return { saved: false, msg: `skill ${input.name} não criada: teto de ${DAILY_LIMIT} skills automáticas por dia` };
68
+ }
69
+ const r = saveSkill(root, input, prev?.scope ?? "project", { auto: true });
70
+ return { saved: true, msg: r.created ? `skill ${input.name} criada em teste (project, v1)` : `skill ${input.name} atualizada (${r.scope}, v${r.version})` };
71
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { exec } from "node:child_process";
3
3
  import { defineTool } from "./registry.js";
4
+ import { shellFor } from "../util/platform.js";
4
5
  defineTool({
5
6
  name: "bash",
6
7
  description: "Executa um comando shell na raiz do projeto (timeout padrão 120s).",
@@ -9,7 +10,7 @@ defineTool({
9
10
  mutates: true,
10
11
  describe: (i) => i.command,
11
12
  run: ({ command, timeout_ms = 120_000 }, { root }) => new Promise((resolve) => {
12
- exec(command, { cwd: root, timeout: timeout_ms, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
+ exec(command, { cwd: root, shell: shellFor(), timeout: timeout_ms, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
14
  const out = (stdout + (stderr ? `\n[stderr]\n${stderr}` : "")).trim();
14
15
  resolve(`${err ? `[exit ${err.code ?? "?"}] ` : ""}${out.slice(-30_000) || "(sem saída)"}`);
15
16
  });
package/dist/tools/fs.js CHANGED
@@ -5,7 +5,10 @@ import fg from "fast-glob";
5
5
  import { defineTool } from "./registry.js";
6
6
  const abs = (root, p) => {
7
7
  const full = path.resolve(root, p);
8
- if (!full.startsWith(path.resolve(root)))
8
+ // path.relative cobre dois defeitos do startsWith: irmão com mesmo prefixo (/proj vs /proj-x) e, no Windows,
9
+ // diferença de caixa na letra da unidade ou nos diretórios (C:\ vs c:\), que fazia caminhos válidos serem recusados.
10
+ const rel = path.relative(path.resolve(root), full);
11
+ if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel))
9
12
  throw new Error("Caminho fora do projeto.");
10
13
  return full;
11
14
  };
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ let claudeCache;
5
+ /** Alvo real de um shim npm (`claude.cmd`): `"%dp0%\caminho\cli.js"` ou `...\claude.exe`. */
6
+ export function resolveCmdShim(shimPath, content) {
7
+ const target = content.match(/"%dp0%\\([^"]+)"/)?.[1];
8
+ if (!target)
9
+ return null;
10
+ const file = path.win32.join(path.win32.dirname(shimPath), target);
11
+ if (/\.exe$/i.test(file))
12
+ return { command: file, prefix: [] };
13
+ if (/\.(c|m)?js$/i.test(file))
14
+ return { command: process.execPath, prefix: [file] };
15
+ return null;
16
+ }
17
+ function findClaude() {
18
+ const found = spawnSync("where", ["claude"], { encoding: "utf8" }).stdout?.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) ?? [];
19
+ const exe = found.find((f) => /\.exe$/i.test(f));
20
+ if (exe)
21
+ return { command: exe, prefix: [] };
22
+ for (const f of found.filter((x) => /\.cmd$/i.test(x))) {
23
+ try {
24
+ const r = resolveCmdShim(f, fs.readFileSync(f, "utf8"));
25
+ if (r && fs.existsSync(r.prefix[0] ?? r.command))
26
+ return r;
27
+ }
28
+ catch { /* shim ilegível: tenta o próximo */ }
29
+ }
30
+ return { command: "claude", prefix: [] };
31
+ }
32
+ /** Comando e argumentos para executar o `claude` com segurança em qualquer sistema. */
33
+ export function claudeCommand(args) {
34
+ if (process.platform !== "win32")
35
+ return { command: "claude", args };
36
+ claudeCache ??= findClaude();
37
+ return { command: claudeCache.command, args: [...claudeCache.prefix, ...args] };
38
+ }
39
+ let shellCache = null;
40
+ /** Shell para `exec`: `BRITO_SHELL` > Git Bash (só no Windows) > padrão do sistema (undefined). */
41
+ export function shellFor() {
42
+ if (process.env.BRITO_SHELL)
43
+ return process.env.BRITO_SHELL;
44
+ if (process.platform !== "win32")
45
+ return undefined;
46
+ if (shellCache !== null)
47
+ return shellCache;
48
+ const roots = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs")].filter((r) => !!r);
49
+ shellCache = roots.map((r) => path.join(r, "Git", "bin", "bash.exe")).find((p) => fs.existsSync(p));
50
+ return shellCache;
51
+ }
52
+ /** Linha para o system prompt dizendo qual shell o comando `bash` usa de fato (só no Windows; nos outros é bash/sh). */
53
+ export function shellNote() {
54
+ if (process.platform !== "win32")
55
+ return "";
56
+ return shellFor() ? "\nShell dos comandos: Git Bash (sintaxe bash)." : "\nShell dos comandos: cmd.exe do Windows (não há bash): use dir/type/findstr e sintaxe do cmd, não ls/cat/grep.";
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brito-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Coding-agent CLI with a code knowledge graph, memory and reviewable skills / CLI de agente de código com grafo de conhecimento, memória e skills revisáveis",
5
5
  "type": "module",
6
6
  "bin": {