sabia 1.1.1 → 2.0.0-alpha.1

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.
Files changed (3) hide show
  1. package/compilar.mjs +373 -0
  2. package/package.json +4 -4
  3. package/sabia.mjs +244 -47
package/compilar.mjs ADDED
@@ -0,0 +1,373 @@
1
+ // Compilador as-built do Sabiá: fluxo/ + padrao/ + skills/ (a fonte, no framework) → skills comuns (SKILL.md +
2
+ // references/) numa pasta de staging, uma por alvo. Seção 11 do plano da 2.0.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const FM = /^---\n([\s\S]*?)\n---\n/;
7
+ const ID = /`(sa-[a-z]+(?:\.[a-z][a-z-]*){1,2})`/g;
8
+ const LINK = /\]\(([^)\s]+)\)/g;
9
+
10
+ const ler = (f) => fs.readFileSync(f, "utf8");
11
+ const json = (f) => JSON.parse(ler(f));
12
+ const skillsDe = (fw) => fs.readdirSync(path.join(fw, "skills")).filter((d) => d === "sabia" || d.startsWith("sa-")).sort();
13
+ const mds = (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort() : []);
14
+
15
+ /** Front-matter JSON (fluxo/, padrao/) e o corpo, com as linhas do front-matter preservadas para o número de linha. */
16
+ export function partes(texto) {
17
+ const m = texto.match(FM);
18
+ return m ? { dados: JSON.parse(m[1]), corpo: texto.slice(m[0].length), pulo: m[0].split("\n").length - 1 } : { dados: null, corpo: texto, pulo: 0 };
19
+ }
20
+
21
+ /** Slug de âncora no estilo do GitHub: minúsculas, letras e números Unicode mantidos, espaço vira "-". */
22
+ export const slug = (titulo) => titulo.trim().toLowerCase().replace(/`/g, "").replace(/[^\p{L}\p{N} _-]/gu, "").replace(/ /g, "-");
23
+ const ancoras = (texto) => new Set([...texto.matchAll(/^#+ (.+)$/gm)].map((m) => slug(m[1])));
24
+
25
+ /** Lê a fonte inteira: fluxos, passos, regras, padrões, travas e papéis. */
26
+ export function carregar(fw) {
27
+ const m = { fw, fluxos: {}, regras: {}, padroes: {}, travas: {}, subag: {}, skillsAgente: {} };
28
+ const dirFluxo = path.join(fw, "fluxo");
29
+ if (fs.existsSync(path.join(dirFluxo, "travas.json"))) m.travas = json(path.join(dirFluxo, "travas.json")).travas;
30
+ const papeis = path.join(fw, "skills/sa-setup/assets/agents/papeis.json");
31
+ if (fs.existsSync(papeis))
32
+ for (const [k, v] of Object.entries(json(papeis).papeis)) {
33
+ m.subag[`sa-${k}`] = new Set(v.subagentes || []);
34
+ m.skillsAgente[`sa-${k}`] = new Set(v.skills || []);
35
+ }
36
+ for (const s of fs.existsSync(dirFluxo) ? fs.readdirSync(dirFluxo).sort() : []) {
37
+ const ds = path.join(dirFluxo, s);
38
+ if (!fs.statSync(ds).isDirectory()) continue;
39
+ if (fs.existsSync(path.join(ds, "regras.md"))) m.regras[s] = { arq: path.join(ds, "regras.md"), ...partes(ler(path.join(ds, "regras.md"))) };
40
+ for (const f of fs.readdirSync(ds).sort()) {
41
+ const mestre = path.join(ds, f, `${f}.md`);
42
+ if (!fs.existsSync(mestre)) continue;
43
+ const p = partes(ler(mestre));
44
+ const passos = (p.dados?.passos || []).map((x) => {
45
+ const arq = path.join(ds, f, "passos", `${x.id}.md`);
46
+ return { ...x, arq, ...(fs.existsSync(arq) ? partes(ler(arq)) : { dados: null, corpo: "", pulo: 0 }) };
47
+ });
48
+ m.fluxos[`${s}.${f}`] = { skill: s, fluxo: f, arq: mestre, d: p.dados || {}, corpo: p.corpo, pulo: p.pulo, passos };
49
+ }
50
+ }
51
+ const dirPadrao = path.join(fw, "padrao");
52
+ for (const s of fs.existsSync(dirPadrao) ? fs.readdirSync(dirPadrao).sort() : [])
53
+ m.padroes[s] = mds(path.join(dirPadrao, s)).map((n) => ({ nome: n, arq: path.join(dirPadrao, s, n), ...partes(ler(path.join(dirPadrao, s, n))) }));
54
+ return m;
55
+ }
56
+
57
+ const fluxosDa = (m, s) => Object.values(m.fluxos).filter((f) => f.skill === s);
58
+
59
+ /** "sa-x.f" ou "sa-x.f.p" existe? Devolve o fluxo e o passo (ou null). */
60
+ function resolverId(m, q) {
61
+ const [s, f, p] = q.split(".");
62
+ const fl = m.fluxos[`${s}.${f}`];
63
+ if (!fl) return null;
64
+ if (p === undefined) return { fl, passo: null };
65
+ const passo = fl.passos.find((x) => x.id === p);
66
+ return passo ? { fl, passo } : null;
67
+ }
68
+
69
+ /** Como o agente roda um fluxo chamado: "em linha" (mesmo dono ou skill do agente), "subagente", ou null (não pode). */
70
+ function modoChamada(m, dono, alvo) {
71
+ if (alvo.d.dono === dono || m.skillsAgente[dono]?.has(alvo.skill)) return "em linha";
72
+ if (m.subag[dono]?.has(alvo.d.dono)) return "subagente";
73
+ return null;
74
+ }
75
+
76
+ // ---------------------------------------------------------------- validação
77
+
78
+ /** Erros "arquivo:linha: mensagem" da fonte (as regras do conferidor da F2.0). Lista vazia = pronta para compilar. */
79
+ export function validar(m) {
80
+ const erros = [];
81
+ const rel = (f) => path.relative(m.fw, f);
82
+ const e = (f, n, msg) => erros.push(`${rel(f)}:${n}: ${msg}`);
83
+ const porSkill = {};
84
+ for (const fl of Object.values(m.fluxos)) (porSkill[fl.skill] ||= []).push(fl);
85
+
86
+ for (const [q, fl] of Object.entries(m.fluxos)) {
87
+ const { d } = fl;
88
+ if (!d.passos?.length || d.fluxo !== fl.fluxo || !d.dono) e(fl.arq, 2, "mestre sem fluxo/dono/passos (ou fluxo ≠ nome da pasta)");
89
+ if (porSkill[fl.skill].length > 1 && !(Array.isArray(d.quando) ? d.quando.length : String(d.quando || "").trim()))
90
+ e(fl.arq, 2, `falta "quando" (a skill ${fl.skill} tem ${porSkill[fl.skill].length} fluxos)`);
91
+ const ids = fl.passos.map((x) => x.id);
92
+ const orfaos = fs.existsSync(path.join(path.dirname(fl.arq), "passos"))
93
+ ? mds(path.join(path.dirname(fl.arq), "passos")).map((f) => f.slice(0, -3)).filter((x) => !ids.includes(x)) : [];
94
+ for (const o of orfaos) e(fl.arq, 2, `passo ${o} órfão (tem arquivo, não está no mestre)`);
95
+ for (const t of d.travas_gerais || []) if (!m.travas[t]) e(fl.arq, 2, `trava ${t} fora do catálogo`);
96
+ for (const x of fl.passos) {
97
+ if (!x.dados) { e(x.arq, 1, `passo ${x.id} sem arquivo ou sem front-matter`); continue; }
98
+ if (!x.dados.resumo) e(x.arq, 2, "passo sem resumo");
99
+ for (const sec of ["## Entrada", "## Faça", "## Saída", "## Pare se"]) if (!x.corpo.includes(sec)) e(x.arq, 1, `falta ${sec}`);
100
+ for (const t of x.travas || []) if (!m.travas[t]) e(fl.arq, 2, `passo ${x.id}: trava ${t} fora do catálogo`);
101
+ for (const c of [].concat(x.chama || [])) {
102
+ const r = resolverId(m, c);
103
+ if (!r) e(fl.arq, 2, `passo ${x.id}: chama ${c} inexistente`);
104
+ else if (!modoChamada(m, d.dono, r.fl)) e(fl.arq, 2, `passo ${x.id}: chama ${c} (dono ${r.fl.d.dono}), mas ${d.dono} não tem a skill nem esse subagente → use passagem (→)`);
105
+ }
106
+ for (const [cond, dest] of Object.entries(x.saidas || {})) {
107
+ if (dest === "fim" || ids.includes(dest) || (dest.startsWith("→ ") && resolverId(m, dest.slice(2).trim()))) continue;
108
+ e(fl.arq, 2, `passo ${x.id}: saída "${cond}" → "${dest}" (use id do fluxo, fim ou "→ skill.fluxo[.passo]" existente)`);
109
+ }
110
+ }
111
+ }
112
+
113
+ const textos = [];
114
+ for (const fl of Object.values(m.fluxos)) {
115
+ textos.push({ arq: fl.arq, corpo: fl.corpo, pulo: fl.pulo, skill: fl.skill, fl });
116
+ for (const x of fl.passos) textos.push({ arq: x.arq, corpo: x.corpo, pulo: x.pulo, skill: fl.skill, fl });
117
+ }
118
+ for (const [s, r] of Object.entries(m.regras)) textos.push({ arq: r.arq, corpo: r.corpo, pulo: r.pulo, skill: s });
119
+ for (const [s, ps] of Object.entries(m.padroes)) for (const p of ps) textos.push({ arq: p.arq, corpo: p.corpo, pulo: p.pulo, skill: s });
120
+ for (const s of skillsDe(m.fw)) {
121
+ const dir = path.join(m.fw, "skills", s);
122
+ for (const f of [path.join(dir, "SKILL.md"), ...mds(path.join(dir, "references")).map((n) => path.join(dir, "references", n))]) {
123
+ if (!fs.existsSync(f)) continue;
124
+ const t = ler(f);
125
+ const fmYaml = t.match(FM);
126
+ const corpo = fmYaml ? t.slice(fmYaml[0].length) : t;
127
+ const pulo = fmYaml ? fmYaml[0].split("\n").length - 1 : 0;
128
+ if (path.basename(f) === "SKILL.md" && porSkill[s]) {
129
+ const resto = corpo.split("\n").filter((l) => l.trim() && !l.startsWith("# "));
130
+ if (resto.length) e(f, pulo + 1, `skill de fluxo com corpo além do H1 (${resto.length} linhas): vai para "quando" ou regras.md`);
131
+ }
132
+ textos.push({ arq: f, corpo, pulo, skill: s });
133
+ }
134
+ }
135
+
136
+ const passoRx = /\bpassos? ((?:`[a-z][a-z-]*`(?:, | e | ou )?)+)/g;
137
+ const fluxoRx = /\bfluxos? ((?:`[a-z][a-z-]*`(?:, | e | ou )?)+)/g;
138
+ const legado = [/`?\bsa-[a-z]+`?\*{0,2},? \(?(?:fluxo|passo)s? `/, /\b(?:fluxo|passo)s? `[a-z-]+`(?: \([^)]*\))? d[ao] \*{0,2}`?sa-[a-z]+/];
139
+ for (const t of textos) {
140
+ let cerca = false;
141
+ t.corpo.split("\n").forEach((linha, i) => {
142
+ const n = t.pulo + i + 1;
143
+ if (linha.trimStart().startsWith("```")) { cerca = !cerca; return; }
144
+ if (/~\/\.sabia\/(fluxo|padrao)\//.test(linha)) e(t.arq, n, "prefixo ~/.sabia/fluxo|padrao/ → ~/.sabia/framework/fluxo|padrao/");
145
+ if (cerca) return;
146
+ for (const [, q] of linha.matchAll(ID)) if (!/\.(md|json|sh|py)$/.test(q) && !resolverId(m, q)) e(t.arq, n, `id inexistente \`${q}\``);
147
+ const semId = linha.replace(ID, "ID");
148
+ for (const rx of legado) if (rx.test(semId)) e(t.arq, n, `referência em prosa → use o id: …${semId.match(rx)[0]}…`);
149
+ for (const [, grupo] of semId.matchAll(passoRx))
150
+ for (const [, x] of grupo.matchAll(/`([a-z][a-z-]*)`/g)) {
151
+ if (t.fl) { if (!t.fl.passos.some((p) => p.id === x)) e(t.arq, n, `passo \`${x}\` não é do fluxo ${t.skill}.${t.fl.fluxo} → use o id`); }
152
+ else if (porSkill[t.skill]) { if (porSkill[t.skill].filter((f) => f.passos.some((p) => p.id === x)).length !== 1) e(t.arq, n, `passo \`${x}\` sem fluxo → use o id`); }
153
+ else e(t.arq, n, `passo \`${x}\` fora de fluxo → use o id`);
154
+ }
155
+ for (const [, grupo] of semId.matchAll(fluxoRx))
156
+ for (const [, x] of grupo.matchAll(/`([a-z][a-z-]*)`/g)) if (!m.fluxos[`${t.skill}.${x}`]) e(t.arq, n, `fluxo \`${x}\` não é da skill ${t.skill} → use o id`);
157
+ for (const mi of semId.matchAll(/\bitens? \d+/g))
158
+ if (/^[^.;]*?(\bd[ao] `?sa-|ID|\bd[ao] passo `)/.test(semId.slice(mi.index + mi[0].length, mi.index + mi[0].length + 70)))
159
+ e(t.arq, n, `"${mi[0]}" de outro arquivo → use id ou âncora`);
160
+ for (const f of ["em `fluxo/`", "abra-os pelo caminho", "abra pelo caminho", "ficam fora do projeto"]) if (linha.includes(f)) e(t.arq, n, `frase de localização "${f}"`);
161
+ if (/`sa-[a-z]+\/[a-z-]+\/passos\/|`sa-[a-z]+\/regras\.md`/.test(linha)) e(t.arq, n, "caminho de fluxo/ em code-span → id do passo");
162
+ });
163
+ }
164
+ return erros;
165
+ }
166
+
167
+ // ---------------------------------------------------------------- reescrita
168
+
169
+ /** Caminho na fonte (absoluto) → caminho relativo à pasta skills/ da saída, ou null se não for do framework. */
170
+ export function mapear(fw, src) {
171
+ const r = path.relative(fw, src).split(path.sep);
172
+ if (r[0] === "skills" && r.length >= 2) return r.slice(1).join("/");
173
+ if (r[0] === "padrao" && r.length === 3) return `${r[1]}/references/padrao/${r[2]}`;
174
+ if (r[0] === "fluxo" && r.length === 3 && r[2] === "regras.md") return `${r[1]}/SKILL.md`;
175
+ if (r[0] === "fluxo" && r.length === 4 && r[3] === `${r[2]}.md`) return `${r[1]}/SKILL.md`;
176
+ if (r[0] === "fluxo" && r.length === 5 && r[3] === "passos") return `${r[1]}/references/${r[2]}/${r[4]}`;
177
+ return null;
178
+ }
179
+
180
+ /** Reescreve links e caminhos do framework para o caminho absoluto do alvo (`base`) e liga ids e padrões. */
181
+ export function reescrever(texto, origem, { fw, base, m }) {
182
+ const destino = (alvo) => {
183
+ const [cam, anc = ""] = alvo.split("#");
184
+ let src;
185
+ if (cam.startsWith("~/.sabia/framework/")) src = path.join(fw, cam.slice("~/.sabia/framework/".length));
186
+ else if (!cam) src = origem;
187
+ else if (cam.startsWith("../") || cam.startsWith("references/") || cam.endsWith("SKILL.md")) src = path.resolve(path.dirname(origem), cam);
188
+ else return null;
189
+ const r = mapear(fw, src);
190
+ return r ? `${base}/${r}${anc ? "#" + anc : ""}` : null;
191
+ };
192
+ let cerca = false;
193
+ return texto.split("\n").map((linha) => {
194
+ if (linha.trimStart().startsWith("```")) { cerca = !cerca; return linha; }
195
+ linha = linha.replace(LINK, (todo, alvo) => {
196
+ if (/^(https?:|mailto:)/.test(alvo) || alvo.includes("<") || alvo.includes("…")) return todo;
197
+ const d = destino(alvo);
198
+ return d ? `](${d})` : todo;
199
+ });
200
+ linha = linha.replace(/~\/\.sabia\/framework\/[^\s)`'"]+/g, (cam) => destino(cam) || cam);
201
+ if (cerca || !m) return linha;
202
+ linha = linha.replace(/(\[)?`(sa-[a-z]+(?:\.[a-z][a-z-]*){1,2})`(\]\()?/g, (todo, abre, q, fecha) => {
203
+ if (abre || fecha || /\.(md|json|sh|py)$/.test(q)) return todo;
204
+ const r = resolverId(m, q);
205
+ if (!r) return todo;
206
+ const alvo = destinoId(base, r);
207
+ return `[\`${q}\`](${alvo})`;
208
+ });
209
+ return linha.replace(/o padrão `([a-z-]+)\/([a-z0-9.-]+\.md)`(?!\])/g, (todo, s, n) =>
210
+ m.padroes[s]?.some((p) => p.nome === n) ? `o padrão [\`${s}/${n}\`](${base}/${s}/references/padrao/${n})` : todo);
211
+ }).join("\n");
212
+ }
213
+
214
+ /** Desce os títulos `n` níveis (fora das cercas de código). */
215
+ function rebaixar(texto, n) {
216
+ let cerca = false;
217
+ return texto.split("\n").map((l) => {
218
+ if (l.trimStart().startsWith("```")) cerca = !cerca;
219
+ return !cerca && /^#{1,5} /.test(l) ? "#".repeat(n) + l : l;
220
+ }).join("\n");
221
+ }
222
+
223
+ /** Tira o H1 do corpo; devolve o título e o resto. */
224
+ function semH1(corpo) {
225
+ const m = corpo.match(/^\s*# (.+)\n/);
226
+ return m ? { titulo: m[1].trim(), resto: corpo.slice(m[0].length).replace(/^\n+/, "") } : { titulo: "", resto: corpo.replace(/^\n+/, "") };
227
+ }
228
+
229
+ /** Link de um id: o arquivo do passo, ou a seção do fluxo no SKILL.md. */
230
+ const destinoId = (base, r) => (r.passo ? `${base}/${r.fl.skill}/references/${r.fl.fluxo}/${r.passo.id}.md` : `${base}/${r.fl.skill}/SKILL.md#${slug(`Fluxo ${r.fl.fluxo}`)}`);
231
+
232
+ const cel = (t) => String(t).replace(/\|/g, "\\|").replace(/\n/g, " ");
233
+ const italico = (resumo) => (resumo ? `_${resumo}_\n\n` : "");
234
+
235
+ const NO_PROJETO = `## No projeto
236
+
237
+ Antes de ler o arquivo de um passo (\`…/skills/<skill>/references/<fluxo>/<passo>.md\`, de qualquer skill), tente abrir
238
+ direto \`.sabia/fluxo/<skill>/<fluxo>/passos/<passo>.md\` na raiz do projeto: se existir, vale ele; senão, o da skill.
239
+ O mesmo para um padrão: \`.sabia/padrao/<skill>/<nome>.md\` no lugar de \`…/references/padrao/<nome>.md\`. Abra pelo
240
+ caminho; não liste a pasta (a busca não vê pastas ocultas). Com agente do Sabiá, o prompt lista também os passos inseridos
241
+ e removidos nos fluxos de que ele é dono.
242
+ `;
243
+
244
+ /** O SKILL.md compilado de uma skill de fluxo. */
245
+ function skillMd(m, s, fmYaml, h1, ctx) {
246
+ const { base } = ctx;
247
+ const fls = fluxosDa(m, s);
248
+ const out = [fmYaml.trimEnd(), "", h1, ""];
249
+ if (m.regras[s]) out.push("## Regras", "", rebaixar(reescrever(semH1(m.regras[s].corpo).resto, m.regras[s].arq, ctx), 1).trimEnd(), "");
250
+ if (fls.length > 1) {
251
+ out.push("## Fluxos", "", "| Fluxo | Quando |", "|---|---|");
252
+ for (const fl of fls) out.push(`| [${fl.fluxo}](#${slug(`Fluxo ${fl.fluxo}`)}) | ${cel([].concat(fl.d.quando || []).join("; "))} |`);
253
+ out.push("");
254
+ }
255
+ const usadas = new Set();
256
+ for (const fl of fls) {
257
+ const { titulo, resto } = semH1(fl.corpo);
258
+ out.push(`## Fluxo ${fl.fluxo}`, "");
259
+ if (titulo) out.push(`**${titulo}**`, "");
260
+ out.push(`Agente: \`${fl.d.dono}\`. Sem ele, sugira à pessoa usá-lo; seguindo aqui, os passos inseridos pelo projeto e os`,
261
+ "desvios de toolkit (⇢) não entram.", "");
262
+ if (resto.trim()) out.push(rebaixar(reescrever(resto, fl.arq, ctx), 1).trimEnd(), "");
263
+ out.push("| # | Passo | Arquivo | Chama | Travas | Saídas |", "|---|---|---|---|---|---|");
264
+ (fl.d.travas_gerais || []).forEach((t) => usadas.add(t));
265
+ fl.passos.forEach((x, i) => {
266
+ (x.travas || []).forEach((t) => usadas.add(t));
267
+ const arq = `${base}/${s}/references/${fl.fluxo}/${x.id}.md`;
268
+ const chama = [].concat(x.chama || []).map((c) => {
269
+ const r = resolverId(m, c);
270
+ const modo = modoChamada(m, fl.d.dono, r.fl);
271
+ const alvo = destinoId(base, r);
272
+ return `[\`${c}\`](${alvo}) (${modo === "subagente" ? `${r.fl.d.dono}, subagente` : "em linha"})`;
273
+ }).join("<br>") || "—";
274
+ const saidas = Object.entries(x.saidas || {}).map(([cond, dest]) => {
275
+ if (dest === "fim") return `${cond} → fim`;
276
+ if (!dest.startsWith("→ ")) return `${cond} → \`${dest}\``;
277
+ const q = dest.slice(2).trim();
278
+ const r = resolverId(m, q);
279
+ const alvo = destinoId(base, r);
280
+ return `${cond} → passa a [\`${q}\`](${alvo})`;
281
+ }).join("<br>") || "—";
282
+ out.push(`| ${i + 1} | \`${x.id}\`${x.desvio ? " ⇢" : ""} | [${x.id}.md](${arq}) | ${cel(chama)} | ${cel((x.travas || []).join(", ") || "—")} | ${cel(saidas)} |`);
283
+ });
284
+ out.push("", "Ao começar um passo, leia o arquivo dele (não os dos outros passos) e o que ele mandar ler. Sem Saídas, segue para o",
285
+ `próximo da lista. Travas do fluxo: ${(fl.d.travas_gerais || []).map((t) => `\`${t}\``).join(", ") || "—"}.`, "");
286
+ }
287
+ const ordem = Object.keys(m.travas).filter((t) => usadas.has(t));
288
+ if (ordem.length) out.push("## Travas", "", "Valem sobre o corpo de qualquer passo.", "", ...ordem.map((t) => `- **${t}** — ${m.travas[t].texto}`), "");
289
+ const padroes = m.padroes[s] || [];
290
+ const refs = mds(path.join(m.fw, "skills", s, "references"));
291
+ if (padroes.length || refs.length) {
292
+ out.push("## Arquivos", "");
293
+ for (const p of padroes) out.push(`- padrão [\`${s}/${p.nome}\`](${base}/${s}/references/padrao/${p.nome})${p.dados?.resumo ? ` — ${p.dados.resumo}` : ""}`);
294
+ for (const r of refs) out.push(`- [\`references/${r}\`](${base}/${s}/references/${r})`);
295
+ out.push("");
296
+ }
297
+ out.push(NO_PROJETO);
298
+ return out.join("\n");
299
+ }
300
+
301
+ const comVersao = (fmYaml, versao) => (versao ? fmYaml.replace(/^(\s+version:\s*)"[^"]*"/m, `$1"${versao}"`) : fmYaml);
302
+
303
+ function copiarArvore(origem, destino, transformar) {
304
+ fs.mkdirSync(destino, { recursive: true });
305
+ for (const n of fs.readdirSync(origem)) {
306
+ if (n === "__pycache__" || n === ".DS_Store") continue;
307
+ const o = path.join(origem, n), d = path.join(destino, n);
308
+ if (fs.statSync(o).isDirectory()) copiarArvore(o, d, transformar);
309
+ else if (n.endsWith(".md")) fs.writeFileSync(d, transformar(ler(o), o));
310
+ else fs.copyFileSync(o, d);
311
+ }
312
+ }
313
+
314
+ /** Confere a saída: todo link para `base` resolve num arquivo do staging (e a âncora existe); nada do layout da fonte sobra. */
315
+ function conferirSaida(staging, base) {
316
+ const erros = [];
317
+ const raiz = path.join(staging, "skills");
318
+ const cache = {};
319
+ const andar = (dir) => fs.readdirSync(dir).flatMap((n) => (fs.statSync(path.join(dir, n)).isDirectory() ? andar(path.join(dir, n)) : n.endsWith(".md") ? [path.join(dir, n)] : []));
320
+ for (const f of andar(raiz)) {
321
+ const t = ler(f);
322
+ const rel = path.relative(staging, f);
323
+ t.split("\n").forEach((l, i) => {
324
+ if (/~\/\.sabia\/(framework\/(skills|fluxo|padrao)|fluxo|padrao)\//.test(l)) erros.push(`${rel}:${i + 1}: caminho da fonte na saída`);
325
+ for (const [, alvo] of l.matchAll(LINK)) {
326
+ if (!alvo.startsWith(base + "/")) continue;
327
+ const [cam, anc] = alvo.slice(base.length + 1).split("#");
328
+ const arq = path.join(raiz, cam);
329
+ if (!fs.existsSync(arq)) { erros.push(`${rel}:${i + 1}: link para ${cam} inexistente`); continue; }
330
+ if (anc && !(cache[arq] ||= ancoras(ler(arq))).has(anc)) erros.push(`${rel}:${i + 1}: âncora #${anc} não existe em ${cam}`);
331
+ }
332
+ });
333
+ }
334
+ return erros;
335
+ }
336
+
337
+ /**
338
+ * Compila o framework `fw` no `staging` (recriado do zero): staging/skills/<s>/… e, com `raiz`, plugin.json, mcp.json,
339
+ * README.md e LICENSE (o power do Kiro). `base` é o caminho absoluto com ~ onde as skills ficam no alvo.
340
+ */
341
+ export function compilar(fw, staging, { base, raiz = false, versao = null } = {}) {
342
+ const m = carregar(fw);
343
+ const erros = validar(m);
344
+ if (erros.length) throw new Error(`a fonte do Sabiá tem ${erros.length} erro(s):\n${erros.slice(0, 40).join("\n")}`);
345
+ fs.rmSync(staging, { recursive: true, force: true });
346
+ const ctx = { fw, base, m };
347
+ const comFluxo = new Set(Object.values(m.fluxos).map((f) => f.skill));
348
+ for (const s of skillsDe(fw)) {
349
+ const dir = path.join(fw, "skills", s), out = path.join(staging, "skills", s);
350
+ copiarArvore(dir, out, (t, o) => reescrever(t, o, ctx));
351
+ const fonte = ler(path.join(dir, "SKILL.md"));
352
+ const fmYaml = (fonte.match(FM) || [""])[0];
353
+ if (comFluxo.has(s)) {
354
+ const h1 = (fonte.slice(fmYaml.length).match(/^# .+$/m) || [`# ${s}`])[0];
355
+ fs.writeFileSync(path.join(out, "SKILL.md"), skillMd(m, s, comVersao(fmYaml, versao), h1, ctx));
356
+ for (const fl of fluxosDa(m, s))
357
+ for (const x of fl.passos) {
358
+ fs.mkdirSync(path.join(out, "references", fl.fluxo), { recursive: true });
359
+ fs.writeFileSync(path.join(out, "references", fl.fluxo, `${x.id}.md`), italico(x.dados.resumo) + reescrever(x.corpo, x.arq, ctx).replace(/^\n+/, ""));
360
+ }
361
+ } else if (fmYaml) {
362
+ fs.writeFileSync(path.join(out, "SKILL.md"), comVersao(fmYaml, versao) + reescrever(fonte.slice(fmYaml.length), path.join(dir, "SKILL.md"), ctx));
363
+ }
364
+ for (const p of m.padroes[s] || []) {
365
+ fs.mkdirSync(path.join(out, "references", "padrao"), { recursive: true });
366
+ fs.writeFileSync(path.join(out, "references", "padrao", p.nome), italico(p.dados?.resumo) + reescrever(p.corpo, p.arq, ctx).replace(/^\n+/, ""));
367
+ }
368
+ }
369
+ if (raiz) for (const f of ["plugin.json", "mcp.json", "README.md", "LICENSE"]) if (fs.existsSync(path.join(fw, f))) fs.copyFileSync(path.join(fw, f), path.join(staging, f));
370
+ const ruins = conferirSaida(staging, base);
371
+ if (ruins.length) throw new Error(`a skill compilada tem ${ruins.length} erro(s):\n${ruins.slice(0, 40).join("\n")}`);
372
+ return staging;
373
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "sabia",
3
- "version": "1.1.1",
4
- "description": "Instala, atualiza e desinstala o Sabiá e os toolkits dele no Kiro, no Claude Code e no opencode, com um inventário em ~/.sabia/.",
3
+ "version": "2.0.0-alpha.1",
4
+ "description": "Instala, atualiza e desinstala o Sabiá e os toolkits dele no Kiro e no opencode, com um inventário em ~/.sabia/, e explica a rota dos fluxos (sabia rota).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sabia": "sabia.mjs"
8
8
  },
9
9
  "files": [
10
- "sabia.mjs"
10
+ "sabia.mjs",
11
+ "compilar.mjs"
11
12
  ],
12
13
  "engines": {
13
14
  "node": ">=18"
@@ -25,7 +26,6 @@
25
26
  "keywords": [
26
27
  "sabia",
27
28
  "kiro",
28
- "claude-code",
29
29
  "opencode",
30
30
  "skills",
31
31
  "agentes"
package/sabia.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Sabiá CLI — instala, atualiza e desinstala o Sabiá e os toolkits no Kiro, no Claude Code e no opencode.
2
+ // Sabiá CLI — instala, atualiza e desinstala o Sabiá e os toolkits no Kiro e no opencode, e explica a rota (`sabia rota`).
3
3
  // ~/.sabia/ é a fonte (framework/ e toolkits/), cada ferramenta recebe uma cópia no padrão dela, e o
4
4
  // ~/.sabia/inventario.json registra cada cópia (com o hash de cada arquivo) e cada MCP: atualizar e desinstalar
5
5
  // mexem só no que está lá. Só a biblioteca padrão do Node 18+.
@@ -10,11 +10,12 @@ import os from "node:os";
10
10
  import path from "node:path";
11
11
  import readline from "node:readline/promises";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { carregar, compilar, partes } from "./compilar.mjs";
13
14
 
14
15
  const VERSAO = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
15
16
  const REPO = "https://github.com/dxaraujo/sabia.git";
16
17
  const INDICE = "https://github.com/dxaraujo/sabia-toolkits.git";
17
- const ALVOS = ["kiro", "claude", "opencode"];
18
+ const ALVOS = ["kiro", "opencode"];
18
19
  const NOME = [
19
20
  " ▄▄▀",
20
21
  "███████╗ █████╗ ██████╗ ██╗ █████╗",
@@ -105,7 +106,6 @@ export const curto = (p) => (p.startsWith(home() + path.sep) ? "~/" + path.relat
105
106
  export const longo = (p) => (p.startsWith("~/") ? path.join(home(), ...p.slice(2).split("/")) : p);
106
107
 
107
108
  export const PASTA_SKILLS = {
108
- claude: () => path.join(home(), ".claude", "skills"),
109
109
  opencode: () => path.join(home(), ".config", "opencode", "skills"),
110
110
  };
111
111
 
@@ -228,7 +228,7 @@ function temComando(cmd) {
228
228
  }
229
229
 
230
230
  export function detectarAlvos() {
231
- return ALVOS.filter((a) => temComando({ kiro: "kiro-cli", claude: "claude", opencode: "opencode" }[a]));
231
+ return ALVOS.filter((a) => temComando({ kiro: "kiro-cli", opencode: "opencode" }[a]));
232
232
  }
233
233
 
234
234
  async function escolherAlvos(opcoes) {
@@ -276,28 +276,29 @@ function tirarMcpDoOpencode(nome) {
276
276
  }
277
277
  }
278
278
 
279
- async function registrarMcps(alvo, fw, reg) {
279
+ /** Os MCPs do framework no opencode (no Kiro, vão no mcp.json do power). */
280
+ async function registrarMcps(fw, reg) {
280
281
  reg.mcp ||= [];
281
- const ja = alvo === "opencode" ? mcpsDoOpencode() : [];
282
+ const ja = mcpsDoOpencode();
282
283
  for (const [nome, s] of Object.entries(servidoresMcp(fw))) {
284
+ if (ja.includes(nome)) { ctx.log(`MCP ${nome} já existe no opencode — mantido`); continue; }
283
285
  const env = Object.entries(s.env || {}).map(([k, v]) => `${k}=${v}`);
284
- let r;
285
- if (alvo === "claude") {
286
- if (ctx.exec("claude", ["mcp", "get", nome]).status === 0) { ctx.log(`MCP ${nome} já existe no Claude Code — mantido`); continue; }
287
- // o nome vem antes do -e, que aceita vários valores e engoliria o nome
288
- r = ctx.exec("claude", ["mcp", "add", "--scope", "user", nome, ...env.flatMap((e) => ["-e", e]), "--", s.command, ...s.args]);
289
- } else {
290
- if (ja.includes(nome)) { ctx.log(`MCP ${nome} já existe no opencode — mantido`); continue; }
291
- r = ctx.exec("opencode", ["mcp", "add", "--global", ...env.flatMap((e) => ["--env", e]), nome, "--", s.command, ...s.args]);
292
- }
293
- if (r.status !== 0) throw new Error(`MCP ${nome} no ${alvo}: ${(r.stderr || r.error?.message || "").trim()}`);
286
+ const r = ctx.exec("opencode", ["mcp", "add", "--global", ...env.flatMap((e) => ["--env", e]), nome, "--", s.command, ...s.args]);
287
+ if (r.status !== 0) throw new Error(`MCP ${nome} no opencode: ${(r.stderr || r.error?.message || "").trim()}`);
294
288
  if (!reg.mcp.includes(nome)) reg.mcp.push(nome);
295
- ctx.log(`MCP ${nome} registrado no ${alvo}`);
289
+ ctx.log(`MCP ${nome} registrado no opencode`);
296
290
  }
297
291
  }
298
292
 
293
+ /** Compila o framework para o alvo em ~/.sabia/compilado/<alvo>/ (seção 11 do plano da 2.0). */
294
+ function compilarPara(alvo, fw) {
295
+ const versao = JSON.parse(fs.readFileSync(path.join(fw, "plugin.json"), "utf8")).version;
296
+ const base = alvo === "kiro" ? "~/.kiro/powers/installed/sabia/skills" : curto(PASTA_SKILLS[alvo]());
297
+ return compilar(fw, sabia("compilado", alvo), { base, raiz: alvo === "kiro", versao });
298
+ }
299
+
299
300
  async function instalarAlvo(alvo, inv, opcoes) {
300
- const fw = sabia("framework");
301
+ const fw = compilarPara(alvo, sabia("framework"));
301
302
  if (alvo === "kiro") {
302
303
  const nosso = inv.alvos.kiro?.power;
303
304
  // o painel Powers instala em ~/.kiro/powers/installed/sabia: fora do inventário, é de outra instalação
@@ -314,11 +315,9 @@ async function instalarAlvo(alvo, inv, opcoes) {
314
315
  }
315
316
  const reg = (inv.alvos[alvo] ||= { copias: {}, mcp: [] });
316
317
  for (const s of skillsDoFluxo(fw)) await copiar(path.join(fw, "skills", s), path.join(PASTA_SKILLS[alvo](), s), reg.copias, opcoes);
317
- if (!opcoes.semMcp) await registrarMcps(alvo, fw, reg);
318
- if (alvo === "opencode") {
319
- const velhas = fs.existsSync(PASTA_SKILLS.opencode()) ? fs.readdirSync(PASTA_SKILLS.opencode()).filter((d) => /^sdd/.test(d)) : [];
320
- if (velhas.length) ctx.log(`aviso: skills antigas no opencode concorrem com as sa-*: ${velhas.join(", ")} (remova se não usa mais)`);
321
- }
318
+ if (!opcoes.semMcp) await registrarMcps(sabia("framework"), reg);
319
+ const velhas = (fs.existsSync(PASTA_SKILLS.opencode()) ? fs.readdirSync(PASTA_SKILLS.opencode()) : []).filter((d) => /^sdd/.test(d));
320
+ if (velhas.length) ctx.log(`aviso: skills antigas no opencode concorrem com as sa-*: ${velhas.join(", ")} (remova se não usa mais)`);
322
321
  }
323
322
 
324
323
  /** Baixa o framework para uma pasta temporária e diz o que muda em relação a ~/.sabia/framework/. */
@@ -353,7 +352,7 @@ export async function instalar(opcoes = {}) {
353
352
  fs.rmSync(sabia("framework"), { recursive: true, force: true });
354
353
  fs.mkdirSync(sabia(), { recursive: true });
355
354
  fs.cpSync(novo.pasta, sabia("framework"), { recursive: true });
356
- inv.framework = { versao: novo.versao, sha: novo.sha, fonte: novo.fonte, instalado_em: new Date().toISOString() };
355
+ inv.framework = { versao: novo.versao, sha: novo.sha, fonte: novo.fonte, compilador: VERSAO, instalado_em: new Date().toISOString() };
357
356
  ctx.log(`Sabiá ${novo.versao} em ~/.sabia/framework`);
358
357
  try {
359
358
  for (const alvo of await escolherAlvos(opcoes)) await instalarAlvo(alvo, inv, opcoes);
@@ -369,13 +368,18 @@ export async function atualizar(opcoes = {}) {
369
368
  if (!inv.framework) throw new Error("o Sabiá não está instalado: rode `sabia instalar`");
370
369
  const novo = baixarFramework({ fonte: inv.framework.fonte === REPO ? undefined : inv.framework.fonte, ...opcoes });
371
370
  ctx.log(`Sabiá ${inv.framework.versao} → ${novo.versao}`);
372
- if (!mostrarDiferencas(diferencas(sabia("framework"), novo.pasta))) return ctx.log("nada mudou");
371
+ // o compilador também conta: a mesma fonte compilada por outro CLI pode dar outra skill
372
+ const compilador = inv.framework.compilador === VERSAO ? null : `compilador ${inv.framework.compilador || "—"} → ${VERSAO}`;
373
+ if (!mostrarDiferencas(diferencas(sabia("framework"), novo.pasta)) && !compilador) return ctx.log("nada mudou");
374
+ if (compilador) ctx.log(` ${compilador}`);
373
375
  if (!/^s/i.test(await ctx.perguntar("Atualizar? [s/N]", "n"))) return ctx.log("nada foi alterado");
374
376
  fs.rmSync(sabia("framework"), { recursive: true, force: true });
375
377
  fs.cpSync(novo.pasta, sabia("framework"), { recursive: true });
376
- inv.framework = { ...inv.framework, versao: novo.versao, sha: novo.sha, instalado_em: new Date().toISOString() };
378
+ inv.framework = { ...inv.framework, versao: novo.versao, sha: novo.sha, compilador: VERSAO, instalado_em: new Date().toISOString() };
377
379
  try {
378
380
  for (const alvo of Object.keys(inv.alvos)) {
381
+ // alvo que saiu do Sabiá (o claude, na 2.0): fica como está; `sabia desinstalar --alvo <ele>` apaga as cópias
382
+ if (!ALVOS.includes(alvo)) { ctx.log(`${alvo}: não é mais alvo do Sabiá (${ALVOS.join(", ")}) — ignorado; tire com sabia desinstalar --alvo ${alvo}`); continue; }
379
383
  if (alvo !== "kiro") { // skill que saiu do framework sai da ferramenta
380
384
  const copias = inv.alvos[alvo].copias;
381
385
  const fica = new Set(skillsDoFluxo(sabia("framework")).map((s) => curto(path.join(PASTA_SKILLS[alvo](), s))));
@@ -401,16 +405,15 @@ export async function desinstalar(opcoes = {}) {
401
405
  for (const tk of Object.values(inv.toolkits)) if (tk.alvos?.[alvo]) { await apagar(tk.alvos[alvo].copias, opcoes); delete tk.alvos[alvo]; }
402
406
  if (alvo === "kiro") ctx.exec("kiro-cli", ["powers", "uninstall", reg.power]);
403
407
  else await apagar(reg.copias, opcoes);
404
- for (const nome of reg.mcp || []) {
405
- if (alvo === "claude") ctx.exec("claude", ["mcp", "remove", "--scope", "user", nome]);
406
- else tirarMcpDoOpencode(nome);
407
- }
408
+ if (alvo === "opencode") for (const nome of reg.mcp || []) tirarMcpDoOpencode(nome);
409
+ else if (reg.mcp?.length) ctx.log(`${alvo}: os MCPs ${reg.mcp.join(", ")} ficam (o Sabiá não mexe mais no ${alvo}); tire à mão se não usa`);
408
410
  if (alvo !== "kiro" && Object.keys(reg.copias).length) continue; // ficou cópia alterada à mão
409
411
  delete inv.alvos[alvo];
410
412
  ctx.log(`${alvo}: Sabiá desinstalado`);
411
413
  }
412
414
  if (!Object.keys(inv.alvos).length) {
413
415
  fs.rmSync(sabia("framework"), { recursive: true, force: true });
416
+ fs.rmSync(sabia("compilado"), { recursive: true, force: true });
414
417
  inv.framework = null;
415
418
  }
416
419
  } finally {
@@ -421,13 +424,16 @@ export async function desinstalar(opcoes = {}) {
421
424
  // ---------- toolkits ----------
422
425
 
423
426
  /** Erros do toolkit.json: campos, nome, caminhos presos à pasta e arquivos que existem (o schema completo é o da setup). */
424
- export function validarToolkit(pasta, nomeEsperado = null) {
427
+ export function validarToolkit(pasta, nomeEsperado = null, fw = null) {
425
428
  const f = path.join(pasta, "toolkit.json");
426
429
  if (!fs.existsSync(f)) return ["falta o toolkit.json"];
427
430
  let m;
428
431
  try { m = JSON.parse(fs.readFileSync(f, "utf8")); } catch (e) { return [`toolkit.json inválido: ${e.message}`]; }
429
432
  const erros = [];
430
- for (const c of ["nome", "versao", "descricao", "tipos-de-projeto", "skills"]) if (!(c in m)) erros.push(`falta o campo ${c}`);
433
+ for (const c of ["nome", "versao", "descricao", "autor", "tipos-de-projeto", "skills"]) if (!(c in m)) erros.push(`falta o campo ${c}`);
434
+ // quem fez responde pelo toolkit: nome e área aparecem no listar e na setup
435
+ if (m.autor && (typeof m.autor.nome !== "string" || typeof m.autor.area !== "string" || !m.autor.nome.trim() || !m.autor.area.trim()))
436
+ erros.push("autor: precisa de nome e area");
431
437
  const nomeOk = (n) => typeof n === "string" && /^[a-z0-9][a-z0-9-]*$/.test(n) && !n.startsWith("sa-");
432
438
  if (!nomeOk(m.nome)) erros.push(`nome inválido (kebab-case, sem sa-): ${m.nome}`);
433
439
  if (nomeEsperado && m.nome !== nomeEsperado) erros.push(`nome ${m.nome} ≠ ${nomeEsperado} do índice`);
@@ -437,9 +443,17 @@ export function validarToolkit(pasta, nomeEsperado = null) {
437
443
  try { if (!fs.existsSync(path.join(pasta, dentro(c, de)))) erros.push(`${de}: não existe ${c}`); } catch (e) { erros.push(e.message); }
438
444
  };
439
445
  const agentes = new Set((m.agentes || []).map((a) => a.nome));
446
+ // sem o framework (toolkits baixar antes do instalar), o gancho é conferido na setup
447
+ const ganchos = fw && fs.existsSync(path.join(fw, "fluxo")) ? carregar(fw) : null;
440
448
  for (const s of m.skills || []) {
441
449
  existe(s.caminho, "skills");
442
450
  if (s.passo?.agente && !agentes.has(s.passo.agente)) erros.push(`passo de ${s.caminho}: agente ${s.passo.agente} não está em agentes`);
451
+ if (s.passo?.passo && ganchos) {
452
+ const [sk, fl, id] = s.passo.passo.split(".");
453
+ const x = ganchos.fluxos[`${sk}.${fl}`]?.passos.find((p) => p.id === id);
454
+ if (!x) erros.push(`passo de ${s.caminho}: ${s.passo.passo} não existe no Sabiá instalado`);
455
+ else if (!x.desvio) erros.push(`passo de ${s.caminho}: ${s.passo.passo} não aceita desvio de toolkit`);
456
+ }
443
457
  }
444
458
  for (const s of m.steering || []) existe(s.caminho, "steering");
445
459
  for (const a of m.agentes || []) {
@@ -463,7 +477,7 @@ export function lerIndice(fonte) {
463
477
  const m = JSON.parse(fs.readFileSync(path.join(raiz, ".claude-plugin", "marketplace.json"), "utf8"));
464
478
  return (m.plugins || []).map((p) => {
465
479
  const s = p.source;
466
- const base = { nome: p.name, versao: p.version, descricao: p.description, indice: fonte };
480
+ const base = { nome: p.name, versao: p.version, descricao: p.description, customiza: p.customiza, autor: p.autor, indice: fonte };
467
481
  if (typeof s === "string") return { ...base, url: local ? path.resolve(raiz, dentro(s.replace(/^\.\//, ""), fonte)) : fonte, subpasta: local ? null : s.replace(/^\.\//, "") };
468
482
  if (s?.source === "github") return { ...base, url: `https://github.com/${s.repo}.git`, ref: s.ref, sha: s.sha };
469
483
  if (s?.source === "url") return { ...base, url: s.url, ref: s.ref, sha: s.sha };
@@ -485,7 +499,7 @@ export async function baixarToolkit(alvo, opcoes = {}) {
485
499
  const [nomeOuUrl, versaoOuRef] = separarRef(alvo);
486
500
  const e = ehUrl ? { nome: null, url: nomeOuUrl, ref: versaoOuRef } : acharNoIndice(nomeOuUrl, versaoOuRef, opcoes);
487
501
  const { pasta, sha } = baixar(e.url, e);
488
- const erros = validarToolkit(pasta, e.nome);
502
+ const erros = validarToolkit(pasta, e.nome, sabia("framework"));
489
503
  if (erros.length) throw new Error(`toolkit recusado:\n- ${erros.join("\n- ")}`);
490
504
  const m = JSON.parse(fs.readFileSync(path.join(pasta, "toolkit.json"), "utf8"));
491
505
  const destino = sabia("toolkits", m.nome);
@@ -521,7 +535,8 @@ export async function instalarToolkit(alvo, opcoes = {}) {
521
535
  // mesmos nomes de skill e de agente: as duas no usuário se sobrescreveriam
522
536
  if (rival) throw new Error(`${baixado} e ${rival} são versões do mesmo toolkit (base ${familia}); instale um só: sabia toolkits remover ${rival}`);
523
537
  const tk = (inv.toolkits[baixado] ||= { versao: m.versao, alvos: {} });
524
- const alvos = opcoes.alvo ? opcoes.alvo.split(",") : Object.keys(inv.alvos).length ? Object.keys(inv.alvos) : await escolherAlvos(opcoes);
538
+ const doInv = Object.keys(inv.alvos).filter((a) => ALVOS.includes(a));
539
+ const alvos = opcoes.alvo || !doInv.length ? await escolherAlvos(opcoes) : doInv;
525
540
  try {
526
541
  for (const a of alvos.filter((x) => x !== "kiro")) {
527
542
  const reg = (tk.alvos[a] ||= { copias: {} });
@@ -567,7 +582,7 @@ export async function atualizarToolkits(nome, opcoes = {}) {
567
582
  const origem = JSON.parse(fs.readFileSync(sabia("toolkits", n, ".origem.json"), "utf8"));
568
583
  const alvo = origem.indice ? n : `${origem.url}${origem.ref ? "@" + origem.ref : ""}`;
569
584
  await instalarToolkit(alvo, { ...opcoes, atualizar: true, indice: origem.indice || opcoes.indice,
570
- alvo: Object.keys(inv.toolkits[n]?.alvos || {}).join(",") || "kiro" });
585
+ alvo: Object.keys(inv.toolkits[n]?.alvos || {}).filter((a) => ALVOS.includes(a)).join(",") || "kiro" });
571
586
  }
572
587
  }
573
588
 
@@ -581,39 +596,212 @@ export function listarToolkits(opcoes = {}) {
581
596
  const inv = lerInventario();
582
597
  const projeto = path.join(process.cwd(), ".sabia", "config", "agentes.json");
583
598
  const doProjeto = fs.existsSync(projeto) ? JSON.parse(fs.readFileSync(projeto, "utf8")).toolkits || {} : {};
584
- const linhas = [];
599
+ // autor e customiza: do toolkit baixado (o toolkit.json) ou, sem ele, da entrada do índice
600
+ const manifesto = (n) => { const f = sabia("toolkits", n, "toolkit.json"); return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : {}; };
601
+ const linhas = [], doIndice = {};
585
602
  for (const i of opcoes.indice ? [opcoes.indice] : indices()) {
586
603
  try {
587
- for (const e of lerIndice(i)) linhas.push([e.nome, e.versao || "—", inv.toolkits[e.nome]?.versao || "—", doProjeto[e.nome]?.versao || "—", e.descricao || ""]);
604
+ for (const e of lerIndice(i)) {
605
+ doIndice[e.nome] = e;
606
+ linhas.push([e.nome, "", e.versao || "—", inv.toolkits[e.nome]?.versao || "—", doProjeto[e.nome]?.versao || "—", e.descricao || ""]);
607
+ }
588
608
  } catch (err) {
589
609
  ctx.log(`índice ${i}: ${err.message}`);
590
610
  }
591
611
  }
592
- for (const [n, t] of Object.entries(inv.toolkits)) if (!linhas.some((l) => l[0] === n)) linhas.push([n, "—", t.versao, doProjeto[n]?.versao || "—", "(fora dos índices)"]);
612
+ for (const [n, t] of Object.entries(inv.toolkits)) if (!linhas.some((l) => l[0] === n)) linhas.push([n, "", "—", t.versao, doProjeto[n]?.versao || "—", "(fora dos índices)"]);
613
+ for (const l of linhas) {
614
+ const a = manifesto(l[0]).autor ?? doIndice[l[0]]?.autor;
615
+ l[1] = a ? `${a.nome} (${a.area})` : "—";
616
+ }
593
617
  // a customização logo abaixo da base, com ↳ e de onde veio
594
618
  for (const l of [...linhas]) {
595
- const f = sabia("toolkits", l[0], "toolkit.json");
596
- const c = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")).customiza : null;
619
+ const c = manifesto(l[0]).customiza ?? doIndice[l[0]]?.customiza;
597
620
  if (!c) continue;
598
621
  linhas.splice(linhas.indexOf(l), 1);
599
622
  const i = linhas.findIndex((b) => b[0] === c.toolkit);
600
623
  // só a primeira mudança (quem customizou), como resumo; a lista inteira está no toolkit.json
601
- l[4] = `customiza ${c.toolkit} ${c.versao}${c.mudancas?.length ? `: ${c.mudancas[0]}${c.mudancas.length > 1 ? "; …" : ""}` : ""}${l[4] ? " — " + l[4] : ""}`;
624
+ l[5] = `customiza ${c.toolkit} ${c.versao}${c.mudancas?.length ? `: ${c.mudancas[0]}${c.mudancas.length > 1 ? "; …" : ""}` : ""}${l[5] ? " — " + l[5] : ""}`;
602
625
  l[0] = `↳ ${l[0]}`;
603
626
  linhas.splice(i < 0 ? linhas.length : i + 1, 0, l);
604
627
  }
605
- ctx.log(["| Toolkit | No índice | Instalado | Neste projeto | Descrição |", "|---|---|---|---|---|",
628
+ ctx.log(["| Toolkit | Autor | No índice | Instalado | Neste projeto | Descrição |", "|---|---|---|---|---|---|",
606
629
  ...linhas.map((l) => `| ${l.join(" | ")} |`)].join("\n"));
607
630
  return linhas;
608
631
  }
609
632
 
633
+ // ---------- rota (diagnóstico, para pessoas) ----------
634
+
635
+ // toolkit 1.x (passo.papel, e "codificador" antes do renome): o mesmo mapa do gerar_agentes.py
636
+ const GANCHO_1X = { desenvolvedor: "sa-codificar.codificar.tarefa", codificador: "sa-codificar.codificar.tarefa", especificador: "sa-prd.prd.escrever" };
637
+ const lerJson = (f, padrao) => (fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : padrao);
638
+ const lerFm = (f) => (fs.existsSync(f) ? partes(fs.readFileSync(f, "utf8")).dados || {} : null);
639
+
640
+ /** Glob do `passo.arquivos`: `**` atravessa `/`; `*` e `?` não. */
641
+ export function casaGlob(glob, arquivo) {
642
+ const rx = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/|\*\*|\*|\?/g, (t) => ({ "**/": "(?:.*/)?", "**": ".*", "*": "[^/]*", "?": "[^/]" })[t]);
643
+ return new RegExp(`^${rx}$`).test(arquivo);
644
+ }
645
+
646
+ /** Colunas alinhadas; a última sem preenchimento. */
647
+ const colunas = (linhas) => {
648
+ const w = [];
649
+ for (const l of linhas) l.slice(0, -1).forEach((c, i) => { w[i] = Math.max(w[i] || 0, c.length); });
650
+ return linhas.map((l) => l.map((c, i) => (i < l.length - 1 ? c.padEnd(w[i]) : c)).join(" ").trimEnd()).join("\n");
651
+ };
652
+
653
+ /** Framework instalado (ou --framework), projeto (ou --projeto) e os desvios ligados nele, como o gerador os resolve. */
654
+ function contextoRota(opcoes) {
655
+ const fw = path.resolve(opcoes.framework || sabia("framework"));
656
+ if (!fs.existsSync(path.join(fw, "fluxo"))) throw new Error(`${curto(fw)} não tem fluxo/: rode \`sabia instalar\` ou passe --framework`);
657
+ const projeto = path.resolve(opcoes.projeto || process.cwd());
658
+ const cfg = lerJson(path.join(projeto, ".sabia", "config", "agentes.json"), {});
659
+ const m = carregar(fw);
660
+ const desvios = (cfg.passos || []).flatMap((e) => {
661
+ const pasta = cfg.toolkits?.[e.toolkit]?.pasta;
662
+ const dir = !pasta ? sabia("toolkits", e.toolkit) : pasta.startsWith("~/") ? longo(pasta) : path.resolve(projeto, pasta);
663
+ const man = lerJson(path.join(dir, "toolkit.json"), null);
664
+ const ext = man?.skills?.find((x) => x.caminho === e.skill && x.passo)?.passo;
665
+ if (!ext) { ctx.log(`aviso: toolkit ${e.toolkit} sem passo em ${e.skill} (${curto(dir)})`); return []; }
666
+ // como o gancho() do gerador: só passo do framework com "desvio": true (senão o gerador recusa e o prompt não o tem)
667
+ const passo = ext.passo || GANCHO_1X[ext.papel], [sk, f, p] = (passo || "").split(".");
668
+ if (!m.fluxos[`${sk}.${f}`]?.passos.find((x) => x.id === p)?.desvio) {
669
+ ctx.log(`aviso: toolkit ${e.toolkit}: ${passo ? `o passo ${passo}` : `o papel ${ext.papel}`} não aceita desvio de toolkit (o gerador recusa) — ignorado`);
670
+ return [];
671
+ }
672
+ const agente = e.agente && ext.agente ? ext.agente : null;
673
+ const skills = agente ? (man.agentes || []).find((a) => a.nome === agente)?.skills || [] : [e.skill];
674
+ return [{ toolkit: e.toolkit, versao: man.versao, passo, para: ext.para, arquivos: ext.arquivos || [],
675
+ agente, skill: path.basename(path.dirname(e.skill)), skills: skills.map((x) => path.basename(path.dirname(x))) }];
676
+ });
677
+ return { fw, projeto, m, versao: lerJson(path.join(fw, "plugin.json"), {}).version || "?", desvios };
678
+ }
679
+
680
+ /** Os passos do fluxo `q` na ordem, com o que o projeto mudou: origem sabia | projeto (corpo trocado) | inserido | removido. */
681
+ function resolverFluxo(c, q) {
682
+ const fl = c.m.fluxos[q.replace("/", ".")];
683
+ if (!fl) throw new Error(`fluxo ${q} não existe em ${curto(c.fw)} (use skill.fluxo, ex.: sa-codificar.codificar)`);
684
+ const dir = path.join(c.projeto, ".sabia", "fluxo", fl.skill, fl.fluxo);
685
+ const ops = lerFm(path.join(dir, `${fl.fluxo}.md`)) || {};
686
+ const doProjeto = (id) => path.join(dir, "passos", `${id}.md`);
687
+ const passos = fl.passos.map((x) => {
688
+ const base = lerFm(doProjeto(x.id));
689
+ const origem = (ops.remover || []).includes(x.id) ? "removido" : base ? "projeto" : "sabia";
690
+ return { ...x, origem, base: base?.base, arq: base ? doProjeto(x.id) : x.arq };
691
+ });
692
+ let falta = [...(ops.inserir || [])];
693
+ while (falta.length) { // em qualquer ordem, inclusive depois de outro inserido
694
+ const prontos = falta.filter((o) => passos.some((x) => x.id === o.depois));
695
+ if (!prontos.length) { ctx.log(`aviso: ${q}: inserir ${falta.map((o) => `${o.id} depois de ${o.depois}`).join(", ")}: o 'depois' não existe`); break; }
696
+ for (const o of prontos) {
697
+ let i = passos.findIndex((x) => x.id === o.depois);
698
+ while (passos[i + 1]?.depois === o.depois) i++;
699
+ passos.splice(i + 1, 0, { id: o.id, depois: o.depois, origem: "inserido", arq: doProjeto(o.id) });
700
+ }
701
+ falta = falta.filter((o) => !prontos.includes(o));
702
+ }
703
+ return { fl, q: `${fl.skill}.${fl.fluxo}`, passos };
704
+ }
705
+
706
+ const travasDe = (fl, x) => [...new Set([...(fl.d.travas_gerais || []), ...(x?.travas || [])])].join(", ") || "—";
707
+
708
+ /** `sabia rota <arquivo>`: o passo com gancho cujo desvio casa com o arquivo, o dono, o toolkit, o motivo e as travas. */
709
+ export function rota(arquivo, opcoes = {}) {
710
+ const c = contextoRota(opcoes);
711
+ let rel = path.relative(c.projeto, path.resolve(arquivo));
712
+ if (rel.startsWith("..")) rel = arquivo; // fora do projeto: o caminho como veio
713
+ rel = rel.split(path.sep).join("/");
714
+ const ganchos = Object.entries(c.m.fluxos).flatMap(([q, fl]) => fl.passos.filter((x) => x.desvio).map((x) => `${q}.${x.id}`));
715
+ let casou = null;
716
+ for (const d of c.desvios) { const g = d.arquivos.find((a) => casaGlob(a, rel)); if (g) { casou = { d, g }; break; } }
717
+ // sem casar: o passo dos desvios por arquivo do projeto (o de código), senão o primeiro gancho do framework
718
+ const pid = casou?.d.passo || c.desvios.find((d) => d.arquivos.length)?.passo || ganchos[0];
719
+ if (!pid) throw new Error(`${curto(c.fw)} não tem passo com desvio de toolkit`);
720
+ const { fl, passos } = resolverFluxo(c, pid.split(".").slice(0, 2).join("."));
721
+ const x = passos.find((y) => y.id === pid.split(".")[2]);
722
+ if (!x) throw new Error(`o desvio aponta para ${pid}, que não é passo do Sabiá instalado`);
723
+ const agentes = [`.opencode/agents/${fl.d.dono}.md`, `.kiro/agents/${fl.d.dono}.json`].filter((f) => fs.existsSync(path.join(c.projeto, f)));
724
+ const linhas = [
725
+ ["passo", pid, x.origem === "projeto" ? `${path.relative(c.projeto, x.arq)} (projeto)` : `${curto(x.arq)} (Sabiá ${c.versao})`],
726
+ ["dono", fl.d.dono, agentes.join(", ") || "agente não gerado neste projeto"],
727
+ ];
728
+ if (casou) linhas.push(["desvio", casou.d.agente || `skill ${casou.d.skill}`, `toolkit ${casou.d.toolkit} ${casou.d.versao}`],
729
+ ["motivo", `casou ${casou.g}`], ["skills", [fl.skill, ...casou.d.skills].join(", ")]);
730
+ else {
731
+ linhas.push(["desvio", "— fluxo padrão", "nenhum padrão casou"]);
732
+ for (const d of c.desvios.filter((y) => y.passo === pid)) linhas.push(["pelo texto", `"${d.para}" → ${d.agente || `skill ${d.skill}`}`, "o modelo decide pela tarefa"]);
733
+ }
734
+ linhas.push(["travas", travasDe(fl, x)]);
735
+ return colunas(linhas);
736
+ }
737
+
738
+ const sid = (t) => t.replace(/[^A-Za-z0-9_]/g, "_");
739
+ const rotulo = (t) => t.replace(/[;:]/g, ",").replace(/[`"#]/g, "");
740
+
741
+ /** `sabia rota --fluxo <skill.fluxo>`: a tabela resolvida (com o que o projeto mudou) e o diagrama de estados. */
742
+ export function rotaFluxo(q, opcoes = {}) {
743
+ const c = contextoRota(opcoes);
744
+ const r = resolverFluxo(c, q), { fl } = r;
745
+ const desvios = c.desvios.filter((d) => d.passo?.startsWith(`${r.q}.`));
746
+ const origem = (x) => ({ sabia: "Sabiá", projeto: `projeto${x.base ? ` (base ${x.base})` : ""}`,
747
+ inserido: `projeto, inserido depois de ${x.depois}`, removido: "removido no projeto" })[x.origem];
748
+ let n = 0;
749
+ const tabela = [["#", "passo", "origem", "chama", "travas", "saídas"], ...r.passos.map((x) => [
750
+ x.origem === "removido" ? "-" : String(++n), x.id + (x.desvio ? " ⇢" : ""), origem(x), [].concat(x.chama || []).join(", ") || "—",
751
+ (x.travas || []).join(", ") || "—", Object.entries(x.saidas || {}).map(([k, v]) => `${k} → ${v.replace(/^→ /, "passa a ")}`).join("; ") || "—"])];
752
+ const cab = [["fluxo", r.q, `dono ${fl.d.dono}`, `Sabiá ${c.versao}`], ["travas", (fl.d.travas_gerais || []).join(", ") || "—"],
753
+ ...desvios.map((d) => ["desvio ⇢", `${d.passo.split(".")[2]}: ${d.arquivos.join(", ") || `"${d.para}"`}`, `→ ${d.agente || `skill ${d.skill}`}`, `toolkit ${d.toolkit} ${d.versao}`])];
754
+
755
+ const out = ["stateDiagram-v2"], declarados = new Set();
756
+ const estado = (id, nome = id) => {
757
+ const s = sid(id);
758
+ if (s !== nome && !declarados.has(s)) { declarados.add(s); out.push(` state "${nome}" as ${s}`); }
759
+ return s;
760
+ };
761
+ const ativos = r.passos.filter((x) => x.origem !== "removido");
762
+ ativos.forEach((x, i) => {
763
+ const a = estado(x.id), prox = ativos[i + 1];
764
+ if (!i) out.push(` [*] --> ${a}`);
765
+ for (const [cond, dest] of Object.entries(x.saidas || {})) {
766
+ if (dest === "fim") out.push(` ${a} --> [*]: ${rotulo(cond)}`);
767
+ else if (dest.startsWith("→ ")) { const q2 = dest.slice(2).trim(); out.push(` ${a} --> ${estado(`x_${q2}`, q2)}: ${rotulo(cond)} (passagem)`); }
768
+ else if (ativos.some((y) => y.id === dest)) out.push(` ${a} --> ${estado(dest)}: ${rotulo(cond)}`);
769
+ }
770
+ out.push(prox ? ` ${a} --> ${estado(prox.id)}${prox.origem === "inserido" ? ": projeto (inserido)" : ""}` : ` ${a} --> [*]`);
771
+ const nota = [x.chama && `chama ${[].concat(x.chama).join(", ")}`, x.origem === "projeto" && `corpo do projeto${x.base ? ` (base ${x.base})` : ""}`,
772
+ x.desvio && desvios.length && `desvio ⇢ ${desvios.filter((d) => d.passo === `${r.q}.${x.id}`).map((d) => d.agente || d.skill).join(", ")}`].filter(Boolean);
773
+ if (nota.length) out.push(` note right of ${a}: ${rotulo(nota.join(" · "))}`);
774
+ });
775
+ return [colunas(cab), "", colunas(tabela), "", "```mermaid", ...out, "```"].join("\n");
776
+ }
777
+
778
+ /** `sabia rota --mapa`: as arestas entre fluxos de todo o framework (chama e passagens), em Mermaid. */
779
+ export function rotaMapa(opcoes = {}) {
780
+ const c = contextoRota(opcoes);
781
+ const out = ["flowchart LR"], nos = new Set(), vistas = new Set();
782
+ const no = (q) => { const s = sid(q); if (!nos.has(s)) { nos.add(s); out.push(` ${s}["${q}"]`); } return s; };
783
+ for (const [q, fl] of Object.entries(c.m.fluxos))
784
+ for (const x of fl.passos) {
785
+ const arestas = [...[].concat(x.chama || []).map((d) => [d, "-->"]),
786
+ ...Object.values(x.saidas || {}).filter((d) => d.startsWith("→ ")).map((d) => [d.slice(2).trim(), "-.->"])];
787
+ for (const [d, seta] of arestas) {
788
+ const q2 = d.split(".").slice(0, 2).join(".");
789
+ if (vistas.has(`${q} ${seta} ${q2} ${x.id}`)) continue;
790
+ vistas.add(`${q} ${seta} ${q2} ${x.id}`);
791
+ out.push(` ${no(q)} ${seta}|${x.id}| ${no(q2)}`);
792
+ }
793
+ }
794
+ return [`Fluxos do Sabiá ${c.versao}: --> chama (volta ao passo) · -.-> passagem (o fluxo termina e segue no outro); rótulo = passo de origem`,
795
+ "", "```mermaid", ...out, "```"].join("\n");
796
+ }
797
+
610
798
  // ---------- linha de comando ----------
611
799
 
612
800
  const AJUDA = `${BANNER}
613
801
 
614
- sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro, no Claude Code e no opencode
802
+ sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro e no opencode
615
803
 
616
- sabia instalar [--alvo kiro,claude,opencode] [--versao X] [--fonte <pasta|url-git>[@ref]]
804
+ sabia instalar [--alvo kiro,opencode] [--versao X] [--fonte <pasta|url-git>[@ref]]
617
805
  sabia atualizar [--versao X]
618
806
  sabia desinstalar [--alvo …]
619
807
  sabia toolkits listar [--indice <url-git|pasta>]
@@ -621,6 +809,10 @@ sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro, no Claude Code e no
621
809
  sabia toolkits instalar <nome>[@versao] | <url-git>[@ref] [--alvo …] (sem argumento, no projeto: as versões dele)
622
810
  sabia toolkits atualizar [<nome>]
623
811
  sabia toolkits remover <nome>
812
+ sabia rota <arquivo> o passo com desvio de toolkit, o dono, o toolkit, o motivo e as travas
813
+ sabia rota --fluxo <skill.fluxo> o fluxo resolvido no projeto (tabela e diagrama Mermaid)
814
+ sabia rota --mapa as arestas entre fluxos (chama e passagens), em Mermaid
815
+ [--projeto <pasta>] (padrão: a atual) [--framework <pasta>] (padrão: ~/.sabia/framework)
624
816
 
625
817
  --sim responde "sim" às confirmações (cópia alterada à mão continua perguntando, salvo --forcar)
626
818
  --forcar sobrescreve o que não foi instalado pelo Sabiá ou foi alterado à mão
@@ -635,14 +827,17 @@ export function lerArgs(argv) {
635
827
  const a = argv[i];
636
828
  if (!a.startsWith("--")) { posicionais.push(a); continue; }
637
829
  const chave = a.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase());
638
- if (["sim", "forcar", "semMcp", "ajuda", "help"].includes(chave)) opcoes[chave] = true;
830
+ if (["sim", "forcar", "semMcp", "mapa", "ajuda", "help"].includes(chave)) opcoes[chave] = true;
831
+ else if (argv[i + 1] === undefined || argv[i + 1].startsWith("--")) throw new Error(`--${a.slice(2)} precisa de um valor`);
639
832
  else opcoes[chave] = argv[++i];
640
833
  }
641
834
  return { posicionais, opcoes };
642
835
  }
643
836
 
644
837
  export async function main(argv = process.argv.slice(2)) {
645
- const { posicionais: [cmd, sub, arg], opcoes } = lerArgs(argv);
838
+ let args;
839
+ try { args = lerArgs(argv); } catch (e) { console.error(`sabia: ${e.message}`); return 1; }
840
+ const { posicionais: [cmd, sub, arg], opcoes } = args;
646
841
  if (opcoes.sim) { // cópia alterada à mão continua perguntando: só o --forcar passa por cima
647
842
  const antes = ctx.perguntar;
648
843
  ctx.perguntar = async (texto, padrao) => (/alterado à mão/.test(texto) ? antes(texto, padrao) : /\[s\/N\]/.test(texto) ? "s" : padrao);
@@ -656,6 +851,8 @@ export async function main(argv = process.argv.slice(2)) {
656
851
  else if (cmd === "toolkits" && sub === "instalar") await (arg ? instalarToolkit(arg, opcoes) : instalarDoProjeto(opcoes));
657
852
  else if (cmd === "toolkits" && sub === "atualizar") await atualizarToolkits(arg, opcoes);
658
853
  else if (cmd === "toolkits" && sub === "remover" && arg) await removerToolkit(arg, opcoes);
854
+ else if (cmd === "rota" && (sub || opcoes.fluxo || opcoes.mapa))
855
+ ctx.log(opcoes.mapa ? rotaMapa(opcoes) : opcoes.fluxo ? rotaFluxo(opcoes.fluxo, opcoes) : rota(sub, opcoes));
659
856
  else { ctx.log(AJUDA); return cmd && !opcoes.ajuda && !opcoes.help ? 1 : 0; }
660
857
  return 0;
661
858
  } catch (e) {