sabia 1.1.3 → 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 +224 -38
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.3",
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,7 +424,7 @@ 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;
@@ -440,9 +443,17 @@ export function validarToolkit(pasta, nomeEsperado = null) {
440
443
  try { if (!fs.existsSync(path.join(pasta, dentro(c, de)))) erros.push(`${de}: não existe ${c}`); } catch (e) { erros.push(e.message); }
441
444
  };
442
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;
443
448
  for (const s of m.skills || []) {
444
449
  existe(s.caminho, "skills");
445
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
+ }
446
457
  }
447
458
  for (const s of m.steering || []) existe(s.caminho, "steering");
448
459
  for (const a of m.agentes || []) {
@@ -488,7 +499,7 @@ export async function baixarToolkit(alvo, opcoes = {}) {
488
499
  const [nomeOuUrl, versaoOuRef] = separarRef(alvo);
489
500
  const e = ehUrl ? { nome: null, url: nomeOuUrl, ref: versaoOuRef } : acharNoIndice(nomeOuUrl, versaoOuRef, opcoes);
490
501
  const { pasta, sha } = baixar(e.url, e);
491
- const erros = validarToolkit(pasta, e.nome);
502
+ const erros = validarToolkit(pasta, e.nome, sabia("framework"));
492
503
  if (erros.length) throw new Error(`toolkit recusado:\n- ${erros.join("\n- ")}`);
493
504
  const m = JSON.parse(fs.readFileSync(path.join(pasta, "toolkit.json"), "utf8"));
494
505
  const destino = sabia("toolkits", m.nome);
@@ -524,7 +535,8 @@ export async function instalarToolkit(alvo, opcoes = {}) {
524
535
  // mesmos nomes de skill e de agente: as duas no usuário se sobrescreveriam
525
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}`);
526
537
  const tk = (inv.toolkits[baixado] ||= { versao: m.versao, alvos: {} });
527
- 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;
528
540
  try {
529
541
  for (const a of alvos.filter((x) => x !== "kiro")) {
530
542
  const reg = (tk.alvos[a] ||= { copias: {} });
@@ -570,7 +582,7 @@ export async function atualizarToolkits(nome, opcoes = {}) {
570
582
  const origem = JSON.parse(fs.readFileSync(sabia("toolkits", n, ".origem.json"), "utf8"));
571
583
  const alvo = origem.indice ? n : `${origem.url}${origem.ref ? "@" + origem.ref : ""}`;
572
584
  await instalarToolkit(alvo, { ...opcoes, atualizar: true, indice: origem.indice || opcoes.indice,
573
- alvo: Object.keys(inv.toolkits[n]?.alvos || {}).join(",") || "kiro" });
585
+ alvo: Object.keys(inv.toolkits[n]?.alvos || {}).filter((a) => ALVOS.includes(a)).join(",") || "kiro" });
574
586
  }
575
587
  }
576
588
 
@@ -618,13 +630,178 @@ export function listarToolkits(opcoes = {}) {
618
630
  return linhas;
619
631
  }
620
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
+
621
798
  // ---------- linha de comando ----------
622
799
 
623
800
  const AJUDA = `${BANNER}
624
801
 
625
- 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
626
803
 
627
- 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]]
628
805
  sabia atualizar [--versao X]
629
806
  sabia desinstalar [--alvo …]
630
807
  sabia toolkits listar [--indice <url-git|pasta>]
@@ -632,6 +809,10 @@ sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro, no Claude Code e no
632
809
  sabia toolkits instalar <nome>[@versao] | <url-git>[@ref] [--alvo …] (sem argumento, no projeto: as versões dele)
633
810
  sabia toolkits atualizar [<nome>]
634
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)
635
816
 
636
817
  --sim responde "sim" às confirmações (cópia alterada à mão continua perguntando, salvo --forcar)
637
818
  --forcar sobrescreve o que não foi instalado pelo Sabiá ou foi alterado à mão
@@ -646,14 +827,17 @@ export function lerArgs(argv) {
646
827
  const a = argv[i];
647
828
  if (!a.startsWith("--")) { posicionais.push(a); continue; }
648
829
  const chave = a.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase());
649
- 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`);
650
832
  else opcoes[chave] = argv[++i];
651
833
  }
652
834
  return { posicionais, opcoes };
653
835
  }
654
836
 
655
837
  export async function main(argv = process.argv.slice(2)) {
656
- 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;
657
841
  if (opcoes.sim) { // cópia alterada à mão continua perguntando: só o --forcar passa por cima
658
842
  const antes = ctx.perguntar;
659
843
  ctx.perguntar = async (texto, padrao) => (/alterado à mão/.test(texto) ? antes(texto, padrao) : /\[s\/N\]/.test(texto) ? "s" : padrao);
@@ -667,6 +851,8 @@ export async function main(argv = process.argv.slice(2)) {
667
851
  else if (cmd === "toolkits" && sub === "instalar") await (arg ? instalarToolkit(arg, opcoes) : instalarDoProjeto(opcoes));
668
852
  else if (cmd === "toolkits" && sub === "atualizar") await atualizarToolkits(arg, opcoes);
669
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));
670
856
  else { ctx.log(AJUDA); return cmd && !opcoes.ajuda && !opcoes.help ? 1 : 0; }
671
857
  return 0;
672
858
  } catch (e) {