brito-cli 0.8.0 → 0.9.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.
@@ -2,12 +2,17 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
+ import { execFileSync } from "node:child_process";
6
+ import { PROTOCOL } from "./protocol.js";
7
+ import { Panels, graphRootFor } from "./panels.js";
5
8
  import { randomUUID } from "node:crypto";
6
9
  import "../tools/fs.js";
7
10
  import "../tools/bash.js";
8
11
  import "../tools/graph.js";
9
12
  import "../tools/delegate.js";
10
13
  import "../tools/plan.js";
14
+ import "../tools/implement.js";
15
+ import "../tools/spec.js";
11
16
  import "../tools/skills.js";
12
17
  import "../tools/digest.js";
13
18
  import "../tools/todo.js";
@@ -21,13 +26,14 @@ import { openGraphAfterClaude } from "../graph/store.js";
21
26
  import { indexProject } from "../graph/indexer.js";
22
27
  import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
23
28
  import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
24
- import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
29
+ import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
25
30
  import { contextWindowFor, resolveModel } from "../provider/resolve.js";
26
31
  import { PLAN_NOTE, perms } from "../permissions.js";
27
- import { skillsIndex } from "../skills/store.js";
32
+ import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
33
+ import { getSkill, listBuiltins, listSkills, skillsIndex } from "../skills/store.js";
28
34
  /**
29
35
  * Backend do app desktop, controlado por JSON em linhas.
30
- * stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow} {t:"config",model?,effort?,mode?} {t:"reset"} {t:"quit"}
36
+ * stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow,scope?:"session"|"project"} {t:"config",model?,effort?,mode?,orch?} {t:"reset"} {t:"query"} {t:"action"} {t:"quit"}
31
37
  * stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
32
38
  * Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
33
39
  * nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
@@ -35,6 +41,66 @@ import { skillsIndex } from "../skills/store.js";
35
41
  */
36
42
  const MARK = "@@";
37
43
  const emit = (ev) => process.stdout.write(MARK + JSON.stringify(ev) + "\n");
44
+ /** Selo do motor (☁ nuvem / 🌐 endpoint / 💻 local, ou ⚠ FALLBACK), igual ao da barra de status da CLI. */
45
+ const chipOf = (model, fb) => { try {
46
+ return engineChip(model, fb).text;
47
+ }
48
+ catch {
49
+ return model;
50
+ } };
51
+ const BUILTIN_COMMANDS = [
52
+ { name: "clear", description: "limpa a conversa", kind: "builtin" },
53
+ { name: "fallback", description: "estado do fallback Claude → modelo local", kind: "builtin" },
54
+ { name: "help", description: "lista os comandos", kind: "builtin" },
55
+ ];
56
+ /** Arquivos do projeto para o `@`: git ls-files (respeita .gitignore); sem git, uma varredura rasa. */
57
+ let fileCache = null;
58
+ function projectFiles(root) {
59
+ if (fileCache && fileCache.root === root && Date.now() - fileCache.at < 15000)
60
+ return fileCache.files;
61
+ let files = [];
62
+ try {
63
+ files = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8", maxBuffer: 16 << 20, timeout: 5000 }).split("\n").filter(Boolean);
64
+ }
65
+ catch {
66
+ const walk = (dir, depth) => {
67
+ if (depth > 4 || files.length > 5000)
68
+ return;
69
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
70
+ if (e.name.startsWith(".") || e.name === "node_modules")
71
+ continue;
72
+ const rel = path.relative(root, path.join(dir, e.name)).split(path.sep).join("/");
73
+ e.isDirectory() ? walk(path.join(dir, e.name), depth + 1) : files.push(rel);
74
+ }
75
+ };
76
+ try {
77
+ walk(root, 0);
78
+ }
79
+ catch { /* pasta ilegível */ }
80
+ }
81
+ fileCache = { root, at: Date.now(), files };
82
+ return files;
83
+ }
84
+ /** "Permitir sempre neste projeto": tipos de ação liberados, guardados por pasta em ~/.brito/desktop-allow.json. */
85
+ const allowFile = () => path.join(os.homedir(), ".brito", "desktop-allow.json");
86
+ function loadAllow(root) {
87
+ try {
88
+ return JSON.parse(fs.readFileSync(allowFile(), "utf8"))[path.resolve(root)] ?? [];
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ function saveAllow(root, kinds) {
95
+ let all = {};
96
+ try {
97
+ all = JSON.parse(fs.readFileSync(allowFile(), "utf8"));
98
+ }
99
+ catch { /* primeiro uso */ }
100
+ all[path.resolve(root)] = kinds;
101
+ fs.mkdirSync(path.dirname(allowFile()), { recursive: true });
102
+ fs.writeFileSync(allowFile(), JSON.stringify(all, null, 1));
103
+ }
38
104
  const sink = {
39
105
  turnStart: () => emit({ t: "turnStart" }),
40
106
  turnEnd: (ok = true) => emit({ t: "turnEnd", ok }),
@@ -42,7 +108,7 @@ const sink = {
42
108
  textEnd: () => emit({ t: "textEnd" }),
43
109
  thinking: (text) => emit({ t: "thinking", text }),
44
110
  toolStart: (id, name, input) => emit({ t: "toolStart", id, name, input }),
45
- toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0, 4000) }),
111
+ toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0, 20000) }),
46
112
  info: (msg) => emit({ t: "info", msg }),
47
113
  warn: (msg) => emit({ t: "warn", msg }),
48
114
  error: (msg) => emit({ t: "error", msg }),
@@ -50,10 +116,15 @@ const sink = {
50
116
  const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
51
117
  export async function serveDesktop(root, resumeId) {
52
118
  const pending = new Map();
53
- const ask = (text, opts) => perms.mode === "bypass" && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
119
+ /** Tipos de pedido liberados com "permitir nesta sessão" (nunca vale para pedidos `force`, como aprovar plano ou skill). */
120
+ const projectAllow = new Set(loadAllow(root));
121
+ const sessionAllow = new Set(projectAllow);
122
+ emit({ t: "hello", protocol: PROTOCOL });
123
+ const kindOf = (text) => text.split(/[:\s]/, 1)[0].toLowerCase();
124
+ const ask = (text, opts) => (perms.mode === "bypass" || sessionAllow.has(kindOf(text))) && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
54
125
  const id = randomUUID();
55
- pending.set(id, resolve);
56
- emit({ t: "approval", id, text, force: !!opts?.force });
126
+ pending.set(id, { resolve, kind: opts?.force ? "" : kindOf(text) });
127
+ emit({ t: "approval", id, text, force: !!opts?.force, kind: kindOf(text) });
57
128
  });
58
129
  // Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
59
130
  const isHome = path.resolve(root) === path.resolve(os.homedir());
@@ -92,8 +163,8 @@ export async function serveDesktop(root, resumeId) {
92
163
  catch { /* sem notas */ }
93
164
  const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
94
165
  const fallback = o.fallback ?? newFallbackState(model);
95
- const system = memoSystem(() => systemKey(root, perms.mode === "plan", syms + notes === 0), () => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""));
96
- const agent = new Agent(system, ctx, { model, sink, fallback });
166
+ const system = memoSystem(() => systemKey(root, perms.mode === "plan", syms + notes === 0) + `|${orch.on}`, () => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : "") + (orch.on ? ORCH_NOTE : ""));
167
+ const agent = new Agent(system, ctx, { model, sink, fallback, tools: mainTools });
97
168
  if (o.messages)
98
169
  agent.messages = o.messages;
99
170
  if (o.effort)
@@ -144,6 +215,7 @@ export async function serveDesktop(root, resumeId) {
144
215
  async send(text) {
145
216
  ac = new AbortController();
146
217
  sink.turnStart();
218
+ orchGuard(agent.model, sink.warn); // troca para o qwen (fallback ou modelo escolhido): não há mais a quem delegar
147
219
  let ok = true;
148
220
  try {
149
221
  await agent.send(text, ac.signal);
@@ -264,10 +336,13 @@ export async function serveDesktop(root, resumeId) {
264
336
  engine = claude;
265
337
  }
266
338
  let busy = false;
267
- const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, symbols: syms, notes, root, fallback: engine.fallback.active });
339
+ restoreOrch(engine.model, (m) => emit({ t: "warn", msg: m }), (m) => emit({ t: "info", msg: m }));
340
+ const panels = new Panels(root, graphRootFor(root), isHome);
341
+ const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, orch: orch.on, symbols: syms, notes, root, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
268
342
  ready();
269
343
  const rl = readline.createInterface({ input: process.stdin });
270
344
  const quit = async () => { closeApproval(); try {
345
+ await panels.close();
271
346
  await engine.close();
272
347
  }
273
348
  catch { /* saindo */ } process.exit(0); };
@@ -294,23 +369,83 @@ export async function serveDesktop(root, resumeId) {
294
369
  sink.turnEnd(true);
295
370
  return;
296
371
  }
372
+ // /nome de uma skill: mesma invocação do REPL (skill_load + contexto).
373
+ let text = String(m.text ?? "");
374
+ const [cmd, ...args] = text.trim().split(/\s+/);
375
+ const sk = cmd.startsWith("/") && !cmd.slice(1).includes("/") ? getSkill(root, cmd.slice(1).toLowerCase()) : null;
376
+ if (sk) {
377
+ const ctxText = args.join(" ");
378
+ text = `Invoquei a skill ${sk.name}. Chame skill_load("${sk.name}") agora e siga-a.${ctxText ? `\n\nContexto: ${ctxText}` : "\n\n(Sem contexto extra: comece perguntando sobre o que quero trabalhar.)"}`;
379
+ }
297
380
  busy = true;
298
381
  try {
299
- await engine.send(String(m.text ?? ""));
382
+ await engine.send(text);
300
383
  }
301
384
  finally {
302
385
  busy = false;
303
- emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active });
386
+ emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
304
387
  }
305
388
  break;
306
389
  }
307
390
  case "abort":
308
391
  engine.abort();
309
392
  break;
310
- case "approve":
311
- pending.get(m.id)?.(!!m.allow);
393
+ case "query": {
394
+ let data = [];
395
+ try {
396
+ const arg = String(m.arg ?? "").toLowerCase();
397
+ if (["skills", "skill", "agents", "symbols", "symbol", "notes"].includes(m.q)) {
398
+ const raw = String(m.arg ?? "");
399
+ data = m.q === "skills" ? panels.skills() : m.q === "skill" ? panels.skill(raw) : m.q === "agents" ? panels.agents()
400
+ : m.q === "symbols" ? await panels.symbols(raw) : m.q === "symbol" ? await panels.symbol(raw) : await panels.notes(raw);
401
+ emit({ t: "result", id: m.id, data });
402
+ break;
403
+ }
404
+ if (m.q === "files")
405
+ data = projectFiles(root).filter((f) => !f.startsWith(".brito/") && (!arg || f.toLowerCase().includes(arg))).sort((a, b) => a.length - b.length).slice(0, 30);
406
+ else if (m.q === "commands") {
407
+ const skills = [...listBuiltins(root), ...listSkills(root).filter((s) => s.invoke === "manual")].map((s) => ({ name: s.name, description: s.description, kind: "skill" }));
408
+ data = [...BUILTIN_COMMANDS, ...skills].filter((c) => !arg || c.name.includes(arg));
409
+ }
410
+ else if (m.q === "models")
411
+ data = { aliases: Object.keys(MODEL_ALIASES), profiles: Object.keys(loadProfiles().profiles) };
412
+ }
413
+ catch (e) {
414
+ sink.warn(`consulta ${m.q} falhou: ${e?.message ?? e}`);
415
+ }
416
+ emit({ t: "result", id: m.id, data });
417
+ break;
418
+ }
419
+ case "action": {
420
+ try {
421
+ const arg = String(m.arg ?? "");
422
+ const msg = m.op === "graph.reindex" ? `grafo reindexado (${(await panels.reindex()).symbols} símbolos)`
423
+ : m.op === "note.delete" ? await panels.deleteNote(arg)
424
+ : panels.skillAction(String(m.op).replace(/^skill\./, ""), arg);
425
+ emit({ t: "result", id: m.id, data: { ok: true, msg } });
426
+ }
427
+ catch (e) {
428
+ emit({ t: "result", id: m.id, data: { ok: false, msg: e?.message ?? String(e) } });
429
+ }
430
+ break;
431
+ }
432
+ case "approve": {
433
+ const p = pending.get(m.id);
434
+ if (p && m.allow && (m.scope === "session" || m.scope === "project") && p.kind) {
435
+ sessionAllow.add(p.kind);
436
+ if (m.scope === "project")
437
+ try {
438
+ projectAllow.add(p.kind);
439
+ saveAllow(root, [...projectAllow]);
440
+ }
441
+ catch (e) {
442
+ sink.warn(`não consegui salvar a permissão: ${e?.message ?? e}`);
443
+ }
444
+ }
445
+ p?.resolve(!!m.allow);
312
446
  pending.delete(m.id);
313
447
  break;
448
+ }
314
449
  case "config": {
315
450
  if (typeof m.model === "string")
316
451
  engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
@@ -318,6 +453,16 @@ export async function serveDesktop(root, resumeId) {
318
453
  engine.effort = isEffort(m.effort) ? m.effort : "";
319
454
  if (["manual", "auto", "plan", "bypass"].includes(m.mode))
320
455
  perms.mode = m.mode;
456
+ // "on" liga o modo orquestrador (se o implementador estiver pronto); "" desliga.
457
+ if (m.orch === "")
458
+ orch.on = false;
459
+ else if (m.orch === "on") {
460
+ const s = implStatus(engine.model, orch.model, process.env);
461
+ if (s.ready)
462
+ orch.on = true;
463
+ else
464
+ sink.warn(`modo orquestrador não ligou: ${s.reason}`);
465
+ }
321
466
  ready();
322
467
  break;
323
468
  }
@@ -3,12 +3,12 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { GraphStore } from "./store.js";
6
- const READER = `const k = require("kuzu");
7
- const db = new k.Database(process.argv[2], 0, true, true);
8
- const c = new k.Connection(db);
9
- c.query("MATCH (n:Note) RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts").then((r) => r.getAll()).then((rows) => {
10
- process.stdout.write(JSON.stringify(rows, (_, v) => (typeof v === "bigint" ? Number(v) : v)));
11
- process.exit(0);
6
+ const READER = `const k = require("kuzu");
7
+ const db = new k.Database(process.argv[2], 0, true, true);
8
+ const c = new k.Connection(db);
9
+ c.query("MATCH (n:Note) RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts").then((r) => r.getAll()).then((rows) => {
10
+ process.stdout.write(JSON.stringify(rows, (_, v) => (typeof v === "bigint" ? Number(v) : v)));
11
+ process.exit(0);
12
12
  });`;
13
13
  /** Copia as notas (memória) do grafo antigo (.brito/graph.kuzu) para o novo (.brito/graph.lbug).
14
14
  * O Kùzu só é instalado numa pasta temporária, durante esta chamada: o pacote principal não depende mais dele. */
@@ -5,6 +5,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
6
  import "../tools/graph.js";
7
7
  import "../tools/skills.js";
8
+ // O implementador do modo orquestrador roda aqui e edita/roda comandos; fs e bash só existem para ele (não são expostos).
9
+ import "../tools/fs.js";
10
+ import "../tools/bash.js";
11
+ import { implementIo } from "../tools/implement.js";
12
+ import "../tools/spec.js";
13
+ import { ORCH_ONLY, orch } from "../orchestrator.js";
8
14
  import "../tools/digest.js";
9
15
  import { allTools, execTool } from "../tools/registry.js";
10
16
  import { GraphStore } from "../graph/store.js";
@@ -28,13 +34,16 @@ export const MCP_INSTRUCTIONS = [
28
34
  * tools de consulta, que são baratas e de uso frequente. As de escrita e a de aprovação (interna) continuam adiadas.
29
35
  */
30
36
  export const ALWAYS_LOAD_META = "anthropic/alwaysLoad";
31
- export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$)/.test(tool);
37
+ export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$|implement$)/.test(tool);
32
38
  /** Prefixo com que o claude CLI enxerga as tools deste servidor. */
33
39
  export const MCP_PREFIX = `mcp__${MCP_NAME}__`;
34
40
  /** Tool usada como --permission-prompt-tool: encaminha o pedido ao REPL do Brito. */
35
41
  export const APPROVE_TOOL = "approve";
36
42
  // fs/bash/delegate o próprio claude já tem (Read/Edit/Bash/Task); aqui vai só o que é do Brito.
37
- const exposed = () => allTools().filter((t) => /^(graph_|skill_)/.test(t.name) || t.name === "remember" || t.name === "recall" || t.name === "digest");
43
+ /** Tools que o claude enxerga; `implement` e `spec_save` só com o modo orquestrador ligado (BRITO_ORCH=1). */
44
+ export const exposed = () => allTools().filter((t) => /^(graph_|skill_)/.test(t.name) || t.name === "remember" || t.name === "recall" || t.name === "digest" || (ORCH_ONLY.includes(t.name) && orch.on));
45
+ /** Nome do pedido de aprovação de uma ação do implementador (o terminal mostra só a pergunta). */
46
+ export const IMPL_ASK = "BritoImplementer";
38
47
  /** Pergunta ao REPL (socket unix) se o claude pode usar a tool; sem socket, nega. */
39
48
  function askRepl(toolName, input) {
40
49
  const sock = process.env.BRITO_APPROVE_SOCK;
@@ -78,7 +87,28 @@ export async function serveMcp(root) {
78
87
  ...(alwaysLoaded(t.name) ? { _meta: { [ALWAYS_LOAD_META]: true } } : {}),
79
88
  }))],
80
89
  }));
81
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
90
+ /**
91
+ * O claude aprovou a chamada de `implement`, não cada edição dentro dela: as do implementador pedem aprovação ao
92
+ * terminal pelo mesmo socket, conforme o modo. O progresso vai como notificação MCP (o stdout daqui é o protocolo).
93
+ */
94
+ const runImplement = async (args, extra, token) => {
95
+ let step = 0;
96
+ implementIo.log = (msg) => {
97
+ if (token === undefined) {
98
+ process.stderr.write(msg + "\n");
99
+ return;
100
+ }
101
+ extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: ++step, message: msg } }).catch(() => { });
102
+ };
103
+ const warn = (msg) => implementIo.log(msg);
104
+ implementIo.sink = { turnStart() { }, turnEnd() { }, text() { }, textEnd() { }, thinking() { }, toolStart() { }, toolEnd() { }, info: warn, warn, error: warn };
105
+ // Quem chama este servidor é sempre o claude CLI: o principal é um Claude, não o modelo salvo no config.
106
+ const implCtx = { ...ctx, ask: async (q) => (await askRepl(IMPL_ASK, { question: q })).allow, model: () => "claude" };
107
+ const r = await execTool("implement", args, implCtx);
108
+ ctx.dirty = true; // o implementador editou arquivos: a próxima consulta ao grafo reindexa
109
+ return r.isError ? `[ERRO] ${r.text}` : r.text;
110
+ };
111
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
82
112
  if (req.params.name === APPROVE_TOOL) {
83
113
  const a = (req.params.arguments ?? {});
84
114
  const r = await askRepl(String(a.tool_name ?? "?"), a.input ?? {});
@@ -88,6 +118,8 @@ export async function serveMcp(root) {
88
118
  if (!exposed().some((t) => t.name === req.params.name)) {
89
119
  return { content: [{ type: "text", text: `Tool desconhecida: ${req.params.name}` }], isError: true };
90
120
  }
121
+ if (req.params.name === "implement")
122
+ return { content: [{ type: "text", text: await runImplement(req.params.arguments ?? {}, extra, req.params._meta?.progressToken) }] };
91
123
  const r = await execTool(req.params.name, req.params.arguments ?? {}, ctx);
92
124
  return { content: [{ type: "text", text: r.text }], isError: r.isError };
93
125
  });
package/dist/model.js CHANGED
@@ -30,6 +30,27 @@ function saveConfig(patch) {
30
30
  writeFileSync(configPath(), JSON.stringify({ ...cfg, ...patch }, null, 2) + "\n");
31
31
  }
32
32
  export const saveModel = (model) => saveConfig({ model });
33
+ /** Perfil que implementa as tasks no modo orquestrador (`implModel` no config). */
34
+ export function loadSavedImplModel() {
35
+ try {
36
+ const m = JSON.parse(readFileSync(configPath(), "utf8"))?.implModel;
37
+ return typeof m === "string" && m.trim() ? m.trim() : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ export const saveImplModel = (implModel) => saveConfig({ implModel });
44
+ /** Modo orquestrador ligado ao abrir o Brito (`orchestrator: true` no config, gravado por `/orquestrar on --global`). */
45
+ export function loadSavedOrch() {
46
+ try {
47
+ return JSON.parse(readFileSync(configPath(), "utf8"))?.orchestrator === true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export const saveOrch = (orchestrator) => saveConfig({ orchestrator });
33
54
  /**
34
55
  * Escada de esforço do Hermes (EFFORT_LADDER, do mais fraco ao mais forte). `none` desliga o raciocínio;
35
56
  * `minimal` e `ultra` são internos: o claude CLI não os aceita, então `clampEffort` os mapeia.
@@ -0,0 +1,131 @@
1
+ import { allTools } from "./tools/registry.js";
2
+ import { loadProfiles, loadSavedImplModel, loadSavedOrch } from "./model.js";
3
+ import { missingKeyMessage } from "./provider/openai-compat.js";
4
+ import { resolveModel } from "./provider/resolve.js";
5
+ // Modo orquestrador: o modelo principal (Claude) planeja e revisa; um perfil de endpoint (o qwen) implementa cada task
6
+ // pela tool `implement`. Estado global da sessão, como o `perms`.
7
+ /** Prioridade: BRITO_IMPL_MODEL > config global (`implModel`) > qwen. */
8
+ export const initialImplModel = () => process.env.BRITO_IMPL_MODEL?.trim() || loadSavedImplModel() || "qwen";
9
+ /** No servidor MCP (outro processo) o modo vem ligado pelo env; no REPL e no app começa desligado. */
10
+ export const orch = { on: process.env.BRITO_ORCH === "1", model: initialImplModel() };
11
+ /** Edição de arquivo fica só com o implementador; o principal lê, delega e verifica (bash continua para os testes). */
12
+ export const ORCH_BLOCKED = ["write_file", "edit_file"];
13
+ /** Tools que só existem com o modo ligado: a delegação e a gravação da spec/tasks em spec/. */
14
+ export const ORCH_ONLY = ["implement", "spec_save"];
15
+ /**
16
+ * Tools do agente principal. Desligado: todas menos as do modo (`implement`, `spec_save`). Ligado: sem as de edição. Função: o modo muda no meio
17
+ * da sessão e o loop relê a lista a cada passo.
18
+ */
19
+ export function mainTools() {
20
+ const hidden = orch.on ? ORCH_BLOCKED : ORCH_ONLY;
21
+ return new Set(allTools().map((t) => t.name).filter((n) => !hidden.includes(n)));
22
+ }
23
+ /**
24
+ * O implementador está pronto? Só um perfil de endpoint válido, com a chave no ambiente, e diferente do principal
25
+ * (o qwen orquestrando o qwen não ganha nada). Relê o config a cada chamada, como os perfis.
26
+ */
27
+ export function implStatus(main, impl, env) {
28
+ const name = impl.trim().toLowerCase();
29
+ if (main.trim().toLowerCase() === name)
30
+ return { ready: false, reason: `o modelo principal já é o ${impl}` };
31
+ const { profiles, errors, rejected } = loadProfiles();
32
+ if (rejected.includes(name)) {
33
+ const why = errors.find((e) => e.toLowerCase().includes(`"${name}"`)) ?? "";
34
+ return { ready: false, reason: `o perfil "${name}" em ~/.brito/config.json está inválido (${why})` };
35
+ }
36
+ const p = profiles[name];
37
+ if (!p || resolveModel(name).kind !== "openai")
38
+ return { ready: false, reason: `sem perfil "${name}" em providers no ~/.brito/config.json` };
39
+ if (!env[p.apiKeyEnv]?.trim())
40
+ return { ready: false, reason: missingKeyMessage(p) };
41
+ return { ready: true, label: p.label };
42
+ }
43
+ /**
44
+ * Desliga o modo quando o implementador deixa de estar pronto para o principal `main` (fallback ou `/model` para o
45
+ * próprio qwen, chave removida…). Nunca em silêncio: o motivo vai para `warn`.
46
+ */
47
+ export function orchGuard(main, warn) {
48
+ if (!orch.on)
49
+ return;
50
+ const s = implStatus(main, orch.model, process.env);
51
+ if (s.ready)
52
+ return;
53
+ orch.on = false;
54
+ warn(`modo orquestrador desligado: ${s.reason}`);
55
+ }
56
+ /**
57
+ * Ao abrir o REPL ou o app: liga o modo se ele foi salvo com `/orquestrar on --global`. Se o implementador não estiver
58
+ * pronto para o principal `main`, fica desligado e o motivo vai para `warn`. O servidor MCP não usa: lá o modo vem do env.
59
+ */
60
+ export function restoreOrch(main, warn, info) {
61
+ if (orch.on || !loadSavedOrch())
62
+ return;
63
+ const s = implStatus(main, orch.model, process.env);
64
+ if (!s.ready) {
65
+ warn(`modo orquestrador salvo como ligado, mas não ligou: ${s.reason}`);
66
+ return;
67
+ }
68
+ orch.on = true;
69
+ info(`modo orquestrador ligado (salvo): ${main} planeja e revisa, ${s.label} implementa`);
70
+ }
71
+ /** O servidor MCP roda em outro processo (subido pelo claude CLI a cada turno): o modo e o perfil chegam pelo env. */
72
+ export const ORCH_ENV = "BRITO_ORCH";
73
+ /** Env do servidor MCP: liga a tool `implement` lá e diz qual perfil implementa. */
74
+ export const orchEnv = () => (orch.on ? { [ORCH_ENV]: "1", BRITO_IMPL_MODEL: orch.model } : {});
75
+ /** Uma task do implementador leva minutos: o timeout padrão do claude CLI para tools MCP cortaria no meio (30 min). */
76
+ export const ORCH_MCP_TIMEOUT = "1800000";
77
+ /** Tools nativas do claude CLI que editam arquivos: bloqueadas com o modo ligado. */
78
+ export const CLAUDE_EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"];
79
+ /** Acrescentado ao system prompt do agente principal enquanto o modo está ligado. */
80
+ export const ORCH_NOTE = `
81
+
82
+ # MODO ORQUESTRADOR ATIVO
83
+ Você planeja e revisa; quem implementa é outro modelo, pela tool implement. Você não tem write_file nem edit_file: sua única escrita é spec_save, que grava em spec/<feature>/.
84
+ - Divida o pedido em tasks pequenas e independentes e mande UMA por chamada de implement, em ordem.
85
+ - Antes da primeira chamada de implement, grave com spec_save (feature = slug curto do pedido):
86
+ - spec.md: objetivo, contexto, requisitos, contratos que não podem mudar, fora de escopo e riscos;
87
+ - tasks.md: lista numerada de tasks como checklist (\`- [ ] 1. título\`), cada uma com objetivo, arquivos, critério de pronto e comando de verificação.
88
+ Depois mostre ao usuário o caminho dos dois arquivos e um resumo da lista, e encerre a resposta pedindo aprovação. Só delegue depois que ele aprovar, seguindo o tasks.md aprovado.
89
+ - Se o usuário pedir mudanças, ou se for preciso mudar a lista no meio do caminho (task nova, ordem diferente), atualize spec.md/tasks.md com spec_save e peça aprovação de novo antes de seguir. Não precisa pedir de novo se o usuário acabou de aprovar um plano com essas mesmas tasks (modo plano).
90
+ - Ao mandar uma task ao implement, cite o caminho da spec (spec/<feature>/spec.md) para ele consultar, mas mantenha a task autocontida.
91
+ - Quando uma task passar na sua revisão, marque-a como feita no tasks.md (\`- [x]\`) com spec_save; se ficar pendente, anote o motivo embaixo dela.
92
+ - O implementador não vê esta conversa: cada task precisa trazer objetivo, arquivos e símbolos envolvidos, contratos que não podem mudar, critério de pronto e o comando de verificação (\`verify\`). Passe \`focus\` com os símbolos centrais.
93
+ - O resultado traz o relato do implementador, o git status/diff e a saída da verificação, rodada pela própria tool. Julgue pelo diff e pela verificação, não pelo relato: leia os trechos alterados quando precisar.
94
+ - Se a task ficou errada ou incompleta, chame implement de novo com o que está errado e o que fazer. No máximo 2 reenvios por task; depois disso, explique ao usuário o que falta.
95
+ - Ao final, resuma o que foi feito, o que a verificação provou e o que ficou pendente, e aponte o tasks.md atualizado.`;
96
+ /** Complemento do ORCH_NOTE no claude CLI, onde os nomes das tools são outros. */
97
+ export const ORCH_NOTE_CLAUDE = `
98
+ - Nesta sessão as tools chegam como mcp__brito__implement e mcp__brito__spec_save; Edit, Write, MultiEdit e NotebookEdit estão bloqueadas. Não use Bash para escrever arquivos: isso também é trabalho do implementador.`;
99
+ /** Prompt do subagente que implementa: escopo fechado, verificação obrigatória e uma resposta que dá para conferir. */
100
+ const IMPLEMENTER_PROMPT = `Você implementa UMA task, descrita no pedido. Você não vê a conversa do orquestrador: tudo que precisa está no pedido.
101
+
102
+ # Escopo
103
+ - Faça só o que a task pede. Não refatore, não renomeie e não "melhore" código fora dela.
104
+ - Não invente arquivo, classe, API ou requisito. Se algo do pedido não existir no projeto, pare e diga isso em PENDENTE.
105
+ - Não mude contratos existentes (assinaturas, rotas, formato de resposta) sem que o pedido mande.
106
+ - Não apague nem enfraqueça testes para a verificação passar.
107
+
108
+ # Como trabalhar
109
+ - Comece pelo grafo (graph_context / graph_callers) dos símbolos citados; leia só as linhas apontadas.
110
+ - Antes de criar um arquivo novo, abra um arquivo existente do mesmo tipo e siga a estrutura dele.
111
+ - Comportamento novo ganha teste, no estilo dos testes que já existem.
112
+
113
+ # Verificação
114
+ - Rode o comando de verificação do pedido (ou o de testes do projeto). Pronto é o que ele prova.
115
+ - Se falhar por causa do código: leia só a parte relevante do erro, corrija e rode de novo. No máximo 3 tentativas.
116
+ - Se falhar por ambiente (serviço fora do ar, porta ocupada), não tente consertar: registre e siga.
117
+
118
+ # Resposta final
119
+ Responda só com:
120
+ FEITO: arquivos criados ou alterados, um por linha
121
+ VERIFICAÇÃO: comando rodado e resultado (passou / falhou / não rodou e por quê)
122
+ PENDENTE: o que faltou, ou "nada"`;
123
+ /** Fora do registro de agentes: não aparece no `delegate` nem vira subagente do claude CLI; só a tool `implement` o usa. */
124
+ export const IMPLEMENTER = {
125
+ name: "implementer",
126
+ description: "Implementa uma task bem especificada (edita arquivos e roda a verificação).",
127
+ tools: "all",
128
+ get model() { return orch.model; },
129
+ prompt: IMPLEMENTER_PROMPT,
130
+ source: "builtin",
131
+ };
@@ -19,9 +19,9 @@ export const MODE_LABEL = {
19
19
  bypass: { icon: "⚡", name: "tudo liberado", hint: "executa tudo sem perguntar" },
20
20
  };
21
21
  /** Acrescentado ao system prompt do backend de API enquanto o modo plano está ativo. */
22
- export const PLAN_NOTE = `
23
-
24
- # MODO PLANO ATIVO
22
+ export const PLAN_NOTE = `
23
+
24
+ # MODO PLANO ATIVO
25
25
  Você só pode ler, pesquisar e consultar o grafo. Edições e comandos shell estão bloqueados. Investigue, monte um plano concreto (arquivos, mudanças, riscos, como verificar) e chame exit_plan com ele. Só execute depois que o usuário aprovar.`;
26
26
  /** Texto do pedido de permissão para uma tool do claude CLI (Bash, Edit, Write…). Primeira linha = resumo. */
27
27
  export function describeClaudeTool(name, input) {
@@ -34,6 +34,10 @@ export function describeClaudeTool(name, input) {
34
34
  case "MultiEdit": return `editar ${i.file_path} (${(i.edits ?? []).length} trocas)`;
35
35
  case "NotebookEdit": return `editar notebook ${i.notebook_path}`;
36
36
  case "ExitPlanMode": return `plano proposto — aprovar e sair do modo plano?\n${String(i.plan ?? "")}`;
37
+ // Ação do implementador do modo orquestrador (servidor MCP): a pergunta já vem montada pelo Brito.
38
+ case "BritoImplementer": return `implementador · ${String(i.question ?? "")}`;
39
+ case "mcp__brito__spec_save": return `gravar spec/${String(i.feature ?? "?")}/${String(i.file ?? "?")} (${String(i.content ?? "").length} chars)`;
40
+ case "mcp__brito__implement": return `delegar ao implementador: ${one(i.task, 300)}${i.verify ? `\n verificação: ${one(i.verify)}` : ""}`;
37
41
  default: return `${name}: ${one(JSON.stringify(i), 200)}`;
38
42
  }
39
43
  }
package/dist/repl.js CHANGED
@@ -20,6 +20,7 @@ const userName = () => {
20
20
  import { listPending, listSkills, listBuiltins, getSkill, suggestSkills, deleteSkill, promoteSkill, globalHome } from "./skills/store.js";
21
21
  import { listAgents } from "./agent/agents.js";
22
22
  import { perms, cycleMode, MODE_LABEL } from "./permissions.js";
23
+ import { orch } from "./orchestrator.js";
23
24
  /**
24
25
  * Backend com o motor substituível no meio da sessão (claude CLI → qwen): o REPL monta o menu uma vez, então os
25
26
  * comandos ficam estáveis e o que o motor atual não tem responde "indisponível". `usage`/`cost` somam todos os
@@ -93,7 +94,10 @@ export async function runRepl(b, o) {
93
94
  ...Object.entries(COMMON).map(([name, desc]) => ({ name, desc })),
94
95
  ].sort((x, y) => x.name.localeCompare(y.name));
95
96
  const modeColor = { manual: c.muted, auto: c.ok, plan: c.warn, bypass: c.err };
96
- const modeChip = () => { const m = MODE_LABEL[perms.mode]; return modeColor[perms.mode](`${m.icon} ${m.name}`); };
97
+ const modeChip = () => {
98
+ const m = MODE_LABEL[perms.mode];
99
+ return modeColor[perms.mode](`${m.icon} ${m.name}`) + (orch.on ? c.ok(` ⚙ →${orch.model}`) : "");
100
+ };
97
101
  const status = () => ui.statusLine({
98
102
  project: path.basename(o.root),
99
103
  mode: modeChip(),