sabia 1.1.3 → 2.0.0-beta.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 +579 -0
  2. package/package.json +4 -4
  3. package/sabia.mjs +334 -60
package/compilar.mjs ADDED
@@ -0,0 +1,579 @@
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.
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
+ export const VIZINHOS = "## Entrada: vizinhos";
26
+ export const SPRINT = ["exige", "nao"];
27
+
28
+ /** As linhas "- <estado> → <destino>" da seção Entrada: vizinhos do corpo do mestre (null sem a seção). */
29
+ function vizinhos(corpo, pulo) {
30
+ const ls = corpo.split("\n"), i = ls.findIndex((l) => l.trim() === VIZINHOS);
31
+ if (i < 0) return null;
32
+ const out = [];
33
+ for (let j = i + 1; j < ls.length && !/^#{1,6} /.test(ls[j]); j++) {
34
+ if (!ls[j].trim()) continue;
35
+ const t = ls[j].trim(), k = t.lastIndexOf(" → ");
36
+ out.push({ n: pulo + j + 1, ...(t.startsWith("- ") && k > 2 ? { estado: t.slice(2, k), destino: t.slice(k + 3) } : {}) });
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** Lê a fonte inteira: fluxos, passos, regras, padrões, travas, tipos e papéis. */
42
+ export function carregar(fw) {
43
+ const m = { fw, fluxos: {}, regras: {}, padroes: {}, travas: {}, tipos: {}, subag: {}, skillsAgente: {} };
44
+ const dirFluxo = path.join(fw, "fluxo");
45
+ if (fs.existsSync(path.join(dirFluxo, "travas.json"))) m.travas = json(path.join(dirFluxo, "travas.json")).travas;
46
+ if (fs.existsSync(path.join(dirFluxo, "tipos.json"))) m.tipos = json(path.join(dirFluxo, "tipos.json")).tipos || {};
47
+ const papeis = path.join(fw, "skills/sa-setup/assets/agents/papeis.json");
48
+ if (fs.existsSync(papeis))
49
+ for (const [k, v] of Object.entries(json(papeis).papeis)) {
50
+ m.subag[`sa-${k}`] = new Set(v.subagentes || []);
51
+ m.skillsAgente[`sa-${k}`] = new Set(v.skills || []);
52
+ }
53
+ for (const s of fs.existsSync(dirFluxo) ? fs.readdirSync(dirFluxo).sort() : []) {
54
+ const ds = path.join(dirFluxo, s);
55
+ if (!fs.statSync(ds).isDirectory()) continue;
56
+ if (fs.existsSync(path.join(ds, "regras.md"))) m.regras[s] = { arq: path.join(ds, "regras.md"), ...partes(ler(path.join(ds, "regras.md"))) };
57
+ for (const f of fs.readdirSync(ds).sort()) {
58
+ const mestre = path.join(ds, f, `${f}.md`);
59
+ if (!fs.existsSync(mestre)) continue;
60
+ const p = partes(ler(mestre));
61
+ const passos = (p.dados?.passos || []).map((x) => {
62
+ // passo compartilhado ({"de": "entrada"}): o corpo vem de fluxo/<de>.md, não de passos/
63
+ const arq = x.de ? path.join(dirFluxo, `${x.de}.md`) : path.join(ds, f, "passos", `${x.id}.md`);
64
+ return { ...x, arq, ...(fs.existsSync(arq) ? partes(ler(arq)) : { dados: null, corpo: "", pulo: 0 }) };
65
+ });
66
+ m.fluxos[`${s}.${f}`] = { skill: s, fluxo: f, arq: mestre, d: p.dados || {}, corpo: p.corpo, pulo: p.pulo, passos, vizinhos: vizinhos(p.corpo, p.pulo) };
67
+ }
68
+ }
69
+ const dirPadrao = path.join(fw, "padrao");
70
+ for (const s of fs.existsSync(dirPadrao) ? fs.readdirSync(dirPadrao).sort() : [])
71
+ 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))) }));
72
+ // os passos de "desviavel" são o ponto de desvio por tipo (⇢): a skill de um toolkit escreve esse artefato
73
+ for (const [sg, t] of Object.entries(m.tipos))
74
+ if (!t.pendente && Array.isArray(t.desviavel)) for (const c of t.desviavel) { const r = resolverId(m, c); if (r?.passo) (r.passo.tipos ||= []).push(sg); }
75
+ return m;
76
+ }
77
+
78
+ /** a < b em versão semântica (os três números). */
79
+ function versaoMenor(a, b) {
80
+ const [x, y] = [a, b].map((v) => String(v).split(/[.-]/).slice(0, 3).map(Number));
81
+ const i = x.findIndex((n, k) => n !== y[k]);
82
+ return i >= 0 && x[i] < y[i];
83
+ }
84
+
85
+ /** O arquivo `f` está dentro da pasta `raiz` (caminhos relativos ou absolutos)? */
86
+ // como o dentro() do gerador: relativo, com /, sem ~, \\, unidade nem ..
87
+ const relativo = (c) => typeof c === "string" && !/^[/~]|\\|^[A-Za-z]:/.test(c) && !c.split("/").includes("..");
88
+ const sob = (raiz, f) => path.resolve(f).startsWith(path.resolve(raiz) + path.sep);
89
+
90
+ /**
91
+ * Erros do toolkit na pasta `tk` contra o Sabiá `fw`: requer, tipos (o tipo vai para uma skill do toolkit, com o fluxo e
92
+ * o padrão do tipo, arquivos da pasta lidos pelo caminho) e rubricas. O Sabiá não compila conteúdo de toolkit.
93
+ */
94
+ export function validarToolkitFonte(fw, tk) {
95
+ const m = carregar(fw), man = json(path.join(tk, "toolkit.json")), erros = [];
96
+ const versaoSabia = fs.existsSync(path.join(fw, "plugin.json")) ? json(path.join(fw, "plugin.json")).version : null;
97
+ if (man.requer?.sabia && versaoSabia && versaoMenor(versaoSabia, man.requer.sabia))
98
+ erros.push(`requer o Sabiá ${man.requer.sabia}; o instalado é o ${versaoSabia}`);
99
+ for (const [sg, q] of Object.entries(man.tipos || {})) {
100
+ const t = m.tipos[sg];
101
+ if (!t) erros.push(`tipo ${sg}: fora do catálogo do Sabiá (o toolkit não cria tipo)`);
102
+ else if (!t.desviavel?.length) erros.push(`tipo ${sg}: não é desviável no catálogo`);
103
+ else if (t.pendente) erros.push(`tipo ${sg}: pendente no Sabiá instalado (ainda sem o passo que o cria)`);
104
+ if (!q || typeof q !== "object") { erros.push(`tipo ${sg}: use {"skill", "fluxo", "padrao"} (a skill do toolkit; o fluxo e o padrão do tipo, opcionais)`); continue; }
105
+ const sk = (man.skills || []).find((s) => s.caminho === q.skill);
106
+ if (!sk) erros.push(`tipo ${sg}: a skill ${q.skill} não está em skills`);
107
+ // o gerador liga a skill ao dono de cada passo de desvio: sem o papel em "papeis", o toolkit não declara esse uso
108
+ else if (t?.desviavel) for (const c of t.desviavel) {
109
+ const papel = resolverId(m, c)?.fl.d.dono?.replace(/^sa-/, "");
110
+ if (papel && !(sk.papeis || []).includes(papel)) erros.push(`tipo ${sg}: a skill ${q.skill} não traz o papel ${papel} em papeis (dono do passo de desvio ${c})`);
111
+ }
112
+ for (const k of ["fluxo", "padrao"].filter((k) => k in q))
113
+ if (!relativo(q[k]) || !sob(tk, path.join(tk, q[k])) || !fs.statSync(path.join(tk, q[k]), { throwIfNoEntry: false })?.isFile()) erros.push(`tipo ${sg}: falta o ${k} ${q[k]} na pasta do toolkit`);
114
+ }
115
+ const doSabia = new Set([...(m.padroes["sa-revisar"]?.find((p) => p.nome === "rubricas.md")?.corpo || "").matchAll(/^\| ([A-Z]\d+) \|/gm)].map((r) => r[1]));
116
+ const ids = (man.rubricas || []).map((r) => r.id);
117
+ for (const r of man.rubricas || []) {
118
+ if (!/^[A-Z]+\d+$/.test(r.id ?? "")) erros.push(`rubrica ${r.id ?? "?"}: id como EX1 (maiúsculas e número)`);
119
+ else if (doSabia.has(r.id) || ids.filter((x) => x === r.id).length > 1) erros.push(`rubrica ${r.id}: repetida ou já do Sabiá (o toolkit só acrescenta itens)`);
120
+ if (!String(r.verificacao ?? "").trim()) erros.push(`rubrica ${r.id ?? "?"}: sem verificacao`);
121
+ if (!["BLOQUEANTE", "IMPORTANTE", "SUGESTÃO"].includes(r.severidade)) erros.push(`rubrica ${r.id ?? "?"}: severidade "${r.severidade ?? ""}" (use BLOQUEANTE, IMPORTANTE ou SUGESTÃO)`);
122
+ }
123
+ return erros;
124
+ }
125
+
126
+ /** As siglas na ordem de publicação: cada uma depois das do seu "depois" (a ordem do catálogo desempata); null com ciclo. */
127
+ export function ordemTipos(tipos) {
128
+ const feitos = [], resto = Object.keys(tipos);
129
+ while (resto.length) {
130
+ const i = resto.findIndex((s) => (tipos[s].depois || []).every((d) => feitos.includes(d) || !tipos[d]));
131
+ if (i < 0) return null;
132
+ feitos.push(...resto.splice(i, 1));
133
+ }
134
+ return feitos;
135
+ }
136
+
137
+ const padraoExiste = (m, ref) => { const [s, n] = ref.split("/"); return !!m.padroes[s]?.some((p) => p.nome === n); };
138
+
139
+ const fluxosDa = (m, s) => Object.values(m.fluxos).filter((f) => f.skill === s);
140
+
141
+ /** "sa-x.f" ou "sa-x.f.p" existe? Devolve o fluxo e o passo (ou null). */
142
+ function resolverId(m, q) {
143
+ const [s, f, p] = q.split(".");
144
+ const fl = m.fluxos[`${s}.${f}`];
145
+ if (!fl) return null;
146
+ if (p === undefined) return { fl, passo: null };
147
+ const passo = fl.passos.find((x) => x.id === p);
148
+ return passo ? { fl, passo } : null;
149
+ }
150
+
151
+ /** Como o agente roda um fluxo chamado: "em linha" (mesmo dono ou skill do agente), "subagente", ou null (não pode). */
152
+ function modoChamada(m, dono, alvo) {
153
+ if (alvo.d.dono === dono || m.skillsAgente[dono]?.has(alvo.skill)) return "em linha";
154
+ if (m.subag[dono]?.has(alvo.d.dono)) return "subagente";
155
+ return null;
156
+ }
157
+
158
+ // ---------------------------------------------------------------- validação
159
+
160
+ /** Erros "arquivo:linha: mensagem" da fonte. Lista vazia = pronta para compilar. */
161
+ export function validar(m) {
162
+ const erros = [];
163
+ const rel = (f) => path.relative(m.fw, f);
164
+ const e = (f, n, msg) => erros.push(`${rel(f)}:${n}: ${msg}`);
165
+ const porSkill = {};
166
+ for (const fl of Object.values(m.fluxos)) (porSkill[fl.skill] ||= []).push(fl);
167
+
168
+ for (const [q, fl] of Object.entries(m.fluxos)) {
169
+ const { d } = fl;
170
+ if (!d.passos?.length || d.fluxo !== fl.fluxo || !d.dono) e(fl.arq, 2, "mestre sem fluxo/dono/passos (ou fluxo ≠ nome da pasta)");
171
+ if (porSkill[fl.skill].length > 1 && !(Array.isArray(d.quando) ? d.quando.length : String(d.quando || "").trim()))
172
+ e(fl.arq, 2, `falta "quando" (a skill ${fl.skill} tem ${porSkill[fl.skill].length} fluxos)`);
173
+ const ids = fl.passos.map((x) => x.id);
174
+ const orfaos = fs.existsSync(path.join(path.dirname(fl.arq), "passos"))
175
+ ? mds(path.join(path.dirname(fl.arq), "passos")).map((f) => f.slice(0, -3)).filter((x) => !ids.includes(x)) : [];
176
+ for (const o of orfaos) e(fl.arq, 2, `passo ${o} órfão (tem arquivo, não está no mestre)`);
177
+ for (const t of d.travas_gerais || []) if (!m.travas[t]) e(fl.arq, 2, `trava ${t} fora do catálogo`);
178
+ if (fl.passos.some((x) => x.de) && !fl.vizinhos?.length)
179
+ e(fl.arq, 2, `passo entrada sem a seção "${VIZINHOS}" no corpo do mestre (uma linha "- <estado> → <dona>" por vizinho)`);
180
+ for (const v of fl.vizinhos || []) {
181
+ const alvos = v.destino ? [...v.destino.matchAll(ID)].map((r) => r[1]) : [];
182
+ if (!alvos.length && !v.destino?.endsWith("■")) { e(fl.arq, v.n, 'vizinho fora do formato "- <estado> → `sa-x.f[.p]`" ou "- <estado> → <frase> ■"'); continue; }
183
+ for (const q2 of alvos) {
184
+ const r = resolverId(m, q2);
185
+ if (!r) continue; // o id inexistente já sai na varredura do texto
186
+ if (r.fl === fl) e(fl.arq, v.n, `vizinho → ${q2}: é deste fluxo → declare como saída do passo entrada ("saidas" no mestre)`);
187
+ else if (!m.subag[r.fl.d.dono]) e(fl.arq, v.n, `vizinho → ${q2}: o dono ${r.fl.d.dono} não é agente do papeis.json`);
188
+ else if (r.passo && r.fl.d.dono !== d.dono) e(fl.arq, v.n, `vizinho → ${q2}: passo de outro agente (${r.fl.d.dono}) → use o fluxo (${r.fl.skill}.${r.fl.fluxo})`);
189
+ else if (r.fl.d.dono !== d.dono && !(v.estado + " → " + v.destino).includes("agente `" + r.fl.d.dono + "`")) e(fl.arq, v.n, `vizinho → ${q2}: dono ${r.fl.d.dono} é outro agente → acrescente "(agente \`${r.fl.d.dono}\`)" na linha`);
190
+ }
191
+ }
192
+ for (const x of fl.passos) {
193
+ if (x.id === "grafo") e(fl.arq, 2, "passo grafo: id reservado (o compilador gera references/<fluxo>/grafo.md)");
194
+ if (x.id === "entrada" && !x.de) e(fl.arq, 2, 'passo entrada: id reservado ao passo compartilhado → {"id": "entrada", "de": "entrada", "sprint": "exige|nao"}');
195
+ if (x.de) {
196
+ if (!SPRINT.includes(x.sprint)) e(fl.arq, 2, `passo ${x.id}: sprint "${x.sprint ?? ""}" (use ${SPRINT.join(" ou ")})`);
197
+ if (fs.existsSync(path.join(path.dirname(fl.arq), "passos", `${x.id}.md`))) e(fl.arq, 2, `passo ${x.id}: vem de fluxo/${x.de}.md; apague passos/${x.id}.md`);
198
+ if (!fs.existsSync(x.arq)) { e(fl.arq, 2, `passo ${x.id}: de "${x.de}" sem fluxo/${x.de}.md`); continue; }
199
+ }
200
+ if (!x.dados) { e(x.arq, 1, `passo ${x.id} sem arquivo ou sem front-matter`); continue; }
201
+ if (!x.dados.resumo) e(x.arq, 2, "passo sem resumo");
202
+ for (const sec of ["## Entrada", "## Faça", "## Saída", "## Pare se"]) if (!x.corpo.includes(sec)) e(x.arq, 1, `falta ${sec}`);
203
+ for (const t of x.travas || []) if (!m.travas[t]) e(fl.arq, 2, `passo ${x.id}: trava ${t} fora do catálogo`);
204
+ for (const c of [].concat(x.chama || [])) {
205
+ const r = resolverId(m, c);
206
+ if (!r) e(fl.arq, 2, `passo ${x.id}: chama ${c} inexistente`);
207
+ 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 (→)`);
208
+ }
209
+ for (const [cond, dest] of Object.entries(x.saidas || {})) {
210
+ const r = dest.startsWith("→ ") ? resolverId(m, dest.slice(2).trim()) : null;
211
+ if (r?.passo && r.fl.d.dono !== d.dono)
212
+ e(fl.arq, 2, `passo ${x.id}: saída "${cond}" passa ao passo ${dest.slice(2).trim()}, de outro agente (${r.fl.d.dono}) → use o fluxo (→ ${r.fl.skill}.${r.fl.fluxo})`);
213
+ if (dest === "fim" || ids.includes(dest) || r) continue;
214
+ e(fl.arq, 2, `passo ${x.id}: saída "${cond}" → "${dest}" (use id do fluxo, fim ou "→ skill.fluxo[.passo]" existente)`);
215
+ }
216
+ }
217
+ }
218
+
219
+ const arqTipos = path.join(m.fw, "fluxo", "tipos.json");
220
+ const rubrica = new Set([...(m.padroes["sa-revisar"]?.find((p) => p.nome === "rubricas.md")?.corpo || "").matchAll(/^\| ([A-Z]\d+) \|/gm)].map((r) => r[1]));
221
+ let forma = true;
222
+ for (const [sg, t] of Object.entries(m.tipos)) {
223
+ const naoLista = ["cria", "desviavel", "depois", "embute", "rubrica"].filter((k) => t[k] != null && !Array.isArray(t[k]));
224
+ for (const k of naoLista) e(arqTipos, 1, `tipo ${sg}: ${k} tem de ser lista`);
225
+ if (naoLista.length) { forma = false; continue; }
226
+ if (!/^[A-Z]{2,4}$/.test(sg) || !t.nome) e(arqTipos, 1, `tipo ${sg}: sigla de 2 a 4 maiúsculas e nome`);
227
+ // sigla única: o JSON.parse junta chaves repetidas; a conferência é no texto
228
+ else if ((ler(arqTipos).match(new RegExp(`"${sg}"\\s*:\\s*\\{`, "g")) || []).length > 1) e(arqTipos, 1, `tipo ${sg}: sigla repetida`);
229
+ if (!["DOORS", "ETM"].includes(t.elm)) e(arqTipos, 1, `tipo ${sg}: elm "${t.elm ?? ""}" (use DOORS ou ETM)`);
230
+ if (!t.cria?.length) e(arqTipos, 1, `tipo ${sg}: sem cria`);
231
+ for (const c of t.desviavel || []) if (!t.cria?.includes(c)) e(arqTipos, 1, `tipo ${sg}: desviavel ${c} fora de cria`);
232
+ // tipo pendente: declarado antes do padrão e do fluxo que o escrevem
233
+ for (const c of t.pendente ? [] : t.cria || []) if (!resolverId(m, c)?.passo) e(arqTipos, 1, `tipo ${sg}: cria ${c} não é passo de fluxo`);
234
+ if (t.padrao && !t.pendente && !padraoExiste(m, t.padrao)) e(arqTipos, 1, `tipo ${sg}: padrão ${t.padrao} inexistente`);
235
+ for (const d of [...(t.depois || []), ...(t.embute || [])]) if (!m.tipos[d]) e(arqTipos, 1, `tipo ${sg}: ${d} fora do catálogo`);
236
+ for (const r of t.pendente ? [] : t.rubrica || []) if (!rubrica.has(r)) e(arqTipos, 1, `tipo ${sg}: rubrica ${r} fora de rubricas.md`);
237
+ if (t.pendente && t.cria?.length && t.cria.every((c) => resolverId(m, c)?.passo) && (!t.padrao || padraoExiste(m, t.padrao)))
238
+ e(arqTipos, 1, `tipo ${sg}: cria e padrão já existem; tire o "pendente"`);
239
+ }
240
+ if (forma && !ordemTipos(m.tipos)) e(arqTipos, 1, `ciclo no "depois" dos tipos`);
241
+
242
+ const textos = [];
243
+ for (const fl of Object.values(m.fluxos)) {
244
+ textos.push({ arq: fl.arq, corpo: fl.corpo, pulo: fl.pulo, skill: fl.skill, fl });
245
+ for (const x of fl.passos) textos.push({ arq: x.arq, corpo: x.corpo, pulo: x.pulo, skill: fl.skill, fl });
246
+ }
247
+ for (const [s, r] of Object.entries(m.regras)) textos.push({ arq: r.arq, corpo: r.corpo, pulo: r.pulo, skill: s });
248
+ 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 });
249
+ for (const s of skillsDe(m.fw)) {
250
+ const dir = path.join(m.fw, "skills", s);
251
+ for (const f of [path.join(dir, "SKILL.md"), ...mds(path.join(dir, "references")).map((n) => path.join(dir, "references", n))]) {
252
+ if (!fs.existsSync(f)) continue;
253
+ const t = ler(f);
254
+ const fmYaml = t.match(FM);
255
+ const corpo = fmYaml ? t.slice(fmYaml[0].length) : t;
256
+ const pulo = fmYaml ? fmYaml[0].split("\n").length - 1 : 0;
257
+ if (path.basename(f) === "SKILL.md" && porSkill[s]) {
258
+ const resto = corpo.split("\n").filter((l) => l.trim() && !l.startsWith("# "));
259
+ 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`);
260
+ }
261
+ textos.push({ arq: f, corpo, pulo, skill: s });
262
+ }
263
+ }
264
+
265
+ const passoRx = /\bpassos? ((?:`[a-z][a-z-]*`(?:, | e | ou )?)+)/g;
266
+ const fluxoRx = /\bfluxos? ((?:`[a-z][a-z-]*`(?:, | e | ou )?)+)/g;
267
+ const legado = [/`?\bsa-[a-z]+`?\*{0,2},? \(?(?:fluxo|passo)s? `/, /\b(?:fluxo|passo)s? `[a-z-]+`(?: \([^)]*\))? d[ao] \*{0,2}`?sa-[a-z]+/];
268
+ for (const t of textos) {
269
+ let cerca = false;
270
+ t.corpo.split("\n").forEach((linha, i) => {
271
+ const n = t.pulo + i + 1;
272
+ if (linha.trimStart().startsWith("```")) { cerca = !cerca; return; }
273
+ if (/~\/\.sabia\/(fluxo|padrao)\//.test(linha)) e(t.arq, n, "prefixo ~/.sabia/fluxo|padrao/ → ~/.sabia/framework/fluxo|padrao/");
274
+ if (cerca) return;
275
+ for (const [, q] of linha.matchAll(ID)) if (!/\.(md|json|sh|py)$/.test(q) && !resolverId(m, q)) e(t.arq, n, `id inexistente \`${q}\``);
276
+ const semId = linha.replace(ID, "ID");
277
+ for (const rx of legado) if (rx.test(semId)) e(t.arq, n, `referência em prosa → use o id: …${semId.match(rx)[0]}…`);
278
+ for (const [, grupo] of semId.matchAll(passoRx))
279
+ for (const [, x] of grupo.matchAll(/`([a-z][a-z-]*)`/g)) {
280
+ 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`); }
281
+ 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`); }
282
+ else e(t.arq, n, `passo \`${x}\` fora de fluxo → use o id`);
283
+ }
284
+ for (const [, grupo] of semId.matchAll(fluxoRx))
285
+ 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`);
286
+ for (const mi of semId.matchAll(/\bitens? \d+/g))
287
+ if (/^[^.;]*?(\bd[ao] `?sa-|ID|\bd[ao] passo `)/.test(semId.slice(mi.index + mi[0].length, mi.index + mi[0].length + 70)))
288
+ e(t.arq, n, `"${mi[0]}" de outro arquivo → use id ou âncora`);
289
+ 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}"`);
290
+ 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");
291
+ });
292
+ }
293
+ return [...new Set(erros)]; // o passo compartilhado repete os mesmos erros em cada fluxo
294
+ }
295
+
296
+ // ---------------------------------------------------------------- reescrita
297
+
298
+ /** Caminho na fonte (absoluto) → caminho relativo à pasta skills/ da saída, ou null se não for do framework. */
299
+ export function mapear(fw, src) {
300
+ const r = path.relative(fw, src).split(path.sep);
301
+ if (r[0] === "skills" && r.length >= 2) return r.slice(1).join("/");
302
+ if (r[0] === "padrao" && r.length === 3) return `${r[1]}/references/padrao/${r[2]}`;
303
+ if (r[0] === "fluxo" && r.length === 3 && r[2] === "regras.md") return `${r[1]}/SKILL.md`;
304
+ if (r[0] === "fluxo" && r.length === 4 && r[3] === `${r[2]}.md`) return `${r[1]}/SKILL.md`;
305
+ if (r[0] === "fluxo" && r.length === 5 && r[3] === "passos") return `${r[1]}/references/${r[2]}/${r[4]}`;
306
+ return null;
307
+ }
308
+
309
+ /** Reescreve links e caminhos do framework para o caminho absoluto do alvo (`base`) e liga ids e padrões. */
310
+ export function reescrever(texto, origem, ctx) {
311
+ const { fw, base, m } = ctx;
312
+ const destino = (alvo) => {
313
+ const [cam, anc = ""] = alvo.split("#");
314
+ let src;
315
+ if (cam.startsWith("~/.sabia/framework/")) src = path.join(fw, cam.slice("~/.sabia/framework/".length));
316
+ else if (!cam) src = origem;
317
+ else if (cam.startsWith("../") || cam.startsWith("references/") || cam.endsWith("SKILL.md")) src = path.resolve(path.dirname(origem), cam);
318
+ else return null;
319
+ const r = mapear(fw, src);
320
+ return r ? `${base}/${r}${anc ? "#" + anc : ""}` : null;
321
+ };
322
+ let cerca = false;
323
+ return texto.split("\n").map((linha) => {
324
+ if (linha.trimStart().startsWith("```")) { cerca = !cerca; return linha; }
325
+ linha = linha.replace(LINK, (todo, alvo) => {
326
+ if (/^(https?:|mailto:)/.test(alvo) || alvo.includes("<") || alvo.includes("…")) return todo;
327
+ const d = destino(alvo);
328
+ return d ? `](${d})` : todo;
329
+ });
330
+ linha = linha.replace(/~\/\.sabia\/framework\/[^\s)`'"]+/g, (cam) => destino(cam) || cam);
331
+ if (cerca || !m) return linha;
332
+ linha = linha.replace(/(\[)?`(sa-[a-z]+(?:\.[a-z][a-z-]*){1,2})`(\]\()?/g, (todo, abre, q, fecha) => {
333
+ if (abre || fecha || /\.(md|json|sh|py)$/.test(q)) return todo;
334
+ const r = resolverId(m, q);
335
+ if (!r) return todo;
336
+ const alvo = destinoId(base, r);
337
+ return `[\`${q}\`](${alvo})`;
338
+ });
339
+ return linha.replace(/o padrão `([a-z-]+)\/([a-z0-9.-]+\.md)`(?!\])/g, (todo, s, n) =>
340
+ m.padroes[s]?.some((p) => p.nome === n) ? `o padrão [\`${s}/${n}\`](${base}/${s}/references/padrao/${n})` : todo);
341
+ }).join("\n");
342
+ }
343
+
344
+ /** Desce os títulos `n` níveis (fora das cercas de código). */
345
+ function rebaixar(texto, n) {
346
+ let cerca = false;
347
+ return texto.split("\n").map((l) => {
348
+ if (l.trimStart().startsWith("```")) cerca = !cerca;
349
+ return !cerca && /^#{1,5} /.test(l) ? "#".repeat(n) + l : l;
350
+ }).join("\n");
351
+ }
352
+
353
+ /** Tira o H1 do corpo; devolve o título e o resto. */
354
+ function semH1(corpo) {
355
+ const m = corpo.match(/^\s*# (.+)\n/);
356
+ return m ? { titulo: m[1].trim(), resto: corpo.slice(m[0].length).replace(/^\n+/, "") } : { titulo: "", resto: corpo.replace(/^\n+/, "") };
357
+ }
358
+
359
+ /** Link de um id: o arquivo do passo, ou a seção do fluxo no SKILL.md. */
360
+ 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}`)}`);
361
+
362
+ const cel = (t) => String(t).replace(/\|/g, "\\|").replace(/\n/g, " ");
363
+ const italico = (resumo) => (resumo ? `_${resumo}_\n\n` : "");
364
+
365
+ export const LEGENDA = "↓ segue · ↑ volta · ↺ repete · ⇄ chama · ⇒ passa a (o fluxo termina) · ↪ vizinhos da entrada (diga a dona, ou a frase da linha ■, e pare) · ■ fim · 🔒 trava · ⇢ passo com desvio de toolkit (pelo para e arquivos, ou pelo tipo que o passo cria)";
366
+
367
+ /** O passo aceita desvio de toolkit: pelo para e arquivos ("desvio": true) ou pelo tipo do catálogo que ele cria. */
368
+ export const temDesvio = (x) => !!(x.desvio || x.tipos?.length);
369
+
370
+ /**
371
+ * O grafo ASCII do fluxo `fl` (de carregar): uma caixa por passo de `passos` (o rota passa os resolvidos no projeto) e, à
372
+ * direita, chama, travas e saídas. `notas(x)` acrescenta linhas no topo das anotações (origem, desvio ligado).
373
+ */
374
+ export function grafoAscii(fl, passos = fl.passos, { notas = () => [] } = {}) {
375
+ const ids = passos.map((x) => x.id), out = [];
376
+ const rot = (x, i) => `${i + 1} ${x.id}${x.sprint ? ` [${x.sprint}]` : ""}${temDesvio(x) ? " ⇢" : ""}`;
377
+ // 26 colunas cabem os ids do Sabiá; um id longo inserido pelo projeto alarga a caixa (par, por causa do ┬ central)
378
+ const W = 2 * Math.ceil(Math.max(26, ...passos.map((x, i) => rot(x, i).length + 4)) / 2);
379
+ const cond = (c) => (c.length > 60 ? c.slice(0, 57) + "…" : c);
380
+ out.push(`${fl.skill}.${fl.fluxo} dono ${fl.d.dono} travas ${(fl.d.travas_gerais || []).join(", ") || "—"}`, "");
381
+ passos.forEach((x, i) => {
382
+ const lado = [...notas(x)];
383
+ // os vizinhos numa linha só: a entrada diz a dona e para, não é passagem de trabalho (⇒)
384
+ const viz = x.de ? (fl.vizinhos || []).filter((v) => v.destino).map((v) => [...v.destino.matchAll(ID)][0]?.[1] || `■ ${cond(v.estado.replace(/`/g, ""))}`) : [];
385
+ if (viz.length) lado.push(`↪ vizinhos (diga e pare): ${[...new Set(viz)].join(" · ")}`);
386
+ if (x.tipos?.length) lado.push(`⇢ por tipo: ${x.tipos.join(", ")}`);
387
+ if (x.chama) lado.push(`⇄ chama ${[].concat(x.chama).join(", ")}`);
388
+ if (x.travas?.length) lado.push(`🔒 ${x.travas.join(", ")}`);
389
+ for (const [c, dest] of Object.entries(x.saidas || {})) {
390
+ const j = ids.indexOf(dest);
391
+ const seta = dest.startsWith("→ ") ? `⇒ passa a ${dest.slice(2).trim()}` : dest === "fim" ? "■ fim" : dest === x.id ? `↺ ${dest}` : j >= 0 && j < i ? `↑ volta a ${dest}` : `↓ ${dest}`;
392
+ lado.push(`${seta} (${cond(c)})`);
393
+ }
394
+ const ultimo = i === passos.length - 1;
395
+ out.push(` ┌${"─".repeat(W - 2)}┐`);
396
+ out.push(` │ ${rot(x, i).padEnd(W - 4)} │${lado[0] ? `──▶ ${lado[0]}` : ""}`);
397
+ out.push((ultimo ? ` └${"─".repeat(W - 2)}┘` : ` └${"─".repeat(W / 2 - 2)}┬${"─".repeat(W / 2 - 1)}┘`) + (lado[1] ? ` ${lado[1]}` : ""));
398
+ for (const l of lado.slice(2)) out.push(`${" ".repeat(W + 6)}${l}`);
399
+ if (!ultimo) out.push(` ${" ".repeat(W / 2 - 1)}▼`);
400
+ });
401
+ return out.join("\n");
402
+ }
403
+
404
+ const grafoMd = (fl) => [`# Grafo de ${fl.skill}.${fl.fluxo}`, "", "```", grafoAscii(fl), "```", "", LEGENDA, ""].join("\n");
405
+
406
+ const NO_PROJETO = `## No projeto
407
+
408
+ Antes de ler o arquivo de um passo (\`…/skills/<skill>/references/<fluxo>/<passo>.md\`, de qualquer skill), tente abrir
409
+ direto \`.sabia/fluxo/<skill>/<fluxo>/passos/<passo>.md\` na raiz do projeto: se existir, vale ele; senão, o da skill.
410
+ O mesmo para um padrão: \`.sabia/padrao/<skill>/<nome>.md\` no lugar de \`…/references/padrao/<nome>.md\`. Abra pelo
411
+ 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
412
+ e removidos nos fluxos de que ele é dono. Num passo ⇢, o desvio de toolkit sai pela skill dele: o agente dono a tem no
413
+ prompt; sem agente, valem a seção **Desvios de toolkit** (desvio por passo) e a **Tipos com skill de toolkit** (desvio por
414
+ tipo, com a skill, o fluxo e o padrão do tipo, arquivos do toolkit lidos pelo caminho, e os itens de rubrica que ele
415
+ acrescenta à revisão) do \`.sabia/sabia.md\`.
416
+ `;
417
+
418
+ /** O SKILL.md compilado de uma skill de fluxo. */
419
+ function skillMd(m, s, fmYaml, h1, ctx) {
420
+ const { base } = ctx;
421
+ const fls = fluxosDa(m, s);
422
+ const out = [fmYaml.trimEnd(), "", h1, ""];
423
+ if (m.regras[s]) out.push("## Regras", "", rebaixar(reescrever(semH1(m.regras[s].corpo).resto, m.regras[s].arq, ctx), 1).trimEnd(), "");
424
+ if (fls.length > 1) {
425
+ out.push("## Fluxos", "", "| Fluxo | Quando |", "|---|---|");
426
+ for (const fl of fls) out.push(`| [${fl.fluxo}](#${slug(`Fluxo ${fl.fluxo}`)}) | ${cel([].concat(fl.d.quando || []).join("; "))} |`);
427
+ out.push("");
428
+ }
429
+ const usadas = new Set();
430
+ for (const fl of fls) {
431
+ const { titulo, resto } = semH1(fl.corpo);
432
+ out.push(`## Fluxo ${fl.fluxo}`, "");
433
+ if (titulo) out.push(`**${titulo}**`, "");
434
+ out.push(`Agente: \`${fl.d.dono}\`. Sem ele, sugira à pessoa usá-lo; seguindo aqui, os passos inseridos e removidos pelo`,
435
+ "projeto não entram, e os desvios de toolkit (⇢) valem pelas seções **Desvios de toolkit** (desvio por passo) e",
436
+ "**Tipos com skill de toolkit** (desvio por tipo) do `.sabia/sabia.md`.", "");
437
+ if (resto.trim()) out.push(rebaixar(reescrever(resto, fl.arq, ctx), 1).trimEnd(), "");
438
+ out.push("| # | Passo | Arquivo | Chama | Travas | Saídas |", "|---|---|---|---|---|---|");
439
+ (fl.d.travas_gerais || []).forEach((t) => usadas.add(t));
440
+ fl.passos.forEach((x, i) => {
441
+ (x.travas || []).forEach((t) => usadas.add(t));
442
+ const arq = `${base}/${s}/references/${fl.fluxo}/${x.id}.md`;
443
+ const chama = [].concat(x.chama || []).map((c) => {
444
+ const r = resolverId(m, c);
445
+ const modo = modoChamada(m, fl.d.dono, r.fl);
446
+ const alvo = destinoId(base, r);
447
+ return `[\`${c}\`](${alvo}) (${modo === "subagente" ? `${r.fl.d.dono}, subagente` : "em linha"})`;
448
+ }).join("<br>") || "—";
449
+ const saidas = Object.entries(x.saidas || {}).map(([cond, dest]) => {
450
+ if (dest === "fim") return `${cond} → fim`;
451
+ if (!dest.startsWith("→ ")) return `${cond} → \`${dest}\``;
452
+ const q = dest.slice(2).trim();
453
+ const r = resolverId(m, q);
454
+ const alvo = destinoId(base, r);
455
+ return `${cond} → passa a [\`${q}\`](${alvo})`;
456
+ }).join("<br>") || "—";
457
+ out.push(`| ${i + 1} | \`${x.id}\`${temDesvio(x) ? ` ⇢${x.tipos?.length ? ` (${x.tipos.join(", ")})` : ""}` : ""} | [${x.id}.md](${arq}) | ${cel(chama)} | ${cel((x.travas || []).join(", ") || "—")} | ${cel(saidas)} |`);
458
+ });
459
+ out.push("", `Grafo: [grafo.md](${base}/${s}/references/${fl.fluxo}/grafo.md)`);
460
+ out.push("", "Ao começar um passo, leia o arquivo dele (não os dos outros passos) e o que ele mandar ler. Nenhuma Saída casou (ou o passo não tem Saídas) → o",
461
+ `próximo da lista; depois do último passo, o fluxo termina. Travas do fluxo: ${(fl.d.travas_gerais || []).map((t) => `\`${t}\``).join(", ") || "—"}.`, "");
462
+ }
463
+ const ordem = Object.keys(m.travas).filter((t) => usadas.has(t));
464
+ if (ordem.length) out.push("## Travas", "", "Valem sobre o corpo de qualquer passo.", "", ...ordem.map((t) => `- **${t}** — ${m.travas[t].texto}`), "");
465
+ const padroes = m.padroes[s] || [];
466
+ const refs = mds(path.join(m.fw, "skills", s, "references"));
467
+ if (padroes.length || refs.length) {
468
+ out.push("## Arquivos", "");
469
+ for (const p of padroes) out.push(`- padrão [\`${s}/${p.nome}\`](${base}/${s}/references/padrao/${p.nome})${p.dados?.resumo ? ` — ${p.dados.resumo}` : ""}`);
470
+ for (const r of refs) out.push(`- [\`references/${r}\`](${base}/${s}/references/${r})`);
471
+ out.push("");
472
+ }
473
+ out.push(NO_PROJETO);
474
+ return out.join("\n");
475
+ }
476
+
477
+ /** references/tipos.md da skill sabia: o catálogo de fluxo/tipos.json em tabela, com a ordem de publicação. */
478
+ function tiposMd(m, ctx) {
479
+ const { base } = ctx;
480
+ const ord = ordemTipos(m.tipos), sim = (b) => (b ? "sim" : "não"), lista = (xs) => (xs?.length ? xs.join(", ") : "—");
481
+ const padrao = (t) => !t.padrao ? "— (corpo no passo que cria)" : t.pendente ? `\`${t.padrao}\`` : `[\`${t.padrao}\`](${base}/${t.padrao.replace("/", "/references/padrao/")})`;
482
+ const out = ["# Tipos de artefato", "",
483
+ "Catálogo fechado: todo artefato da especificação e do ELM é de um destes tipos, e um toolkit não cria tipo. A coluna",
484
+ "Desviável diz os passos de Cria que são o ponto de desvio (⇢): neles, o toolkit que traz uma skill com o fluxo e o",
485
+ "padrão do tipo (`tipos` no `toolkit.json`) escreve esse artefato; nos outros passos de Cria, o tipo é sempre do Sabiá. O envelope OKF de todos",
486
+ `é o do [\`okf-elm.md\`](${base}/sa-prd/references/okf-elm.md) da \`sa-prd\`; o corpo é o do padrão do tipo. Tipo **pendente** está declarado, mas ainda não tem padrão nem fluxo: não é criado nem publicado.`, "",
487
+ "| Sigla | Nome | Arquivo | Cria | Padrão | ELM | Requisito | Desviável | Publica depois de | Embute | Rubrica |",
488
+ "|---|---|---|---|---|---|---|---|---|---|---|"];
489
+ for (const sg of Object.keys(m.tipos)) {
490
+ const t = m.tipos[sg];
491
+ out.push(`| \`${sg}\`${t.pendente ? " (pendente)" : ""} | ${cel(t.nome)} | \`${cel(t.arquivo || "—")}\` | ${t.cria.map((c) => `\`${c}\``).join("<br>")} | ${padrao(t)} | ${t.elm} | ${sim(t.requisito)} | ${t.desviavel?.length ? t.desviavel.map((c) => `\`${c}\``).join("<br>") : "não"} | ${lista(t.depois)} | ${lista(t.embute)} | ${lista(t.rubrica)} |`);
492
+ }
493
+ const pub = ord.filter((sg) => m.tipos[sg].requisito && !m.tipos[sg].pendente);
494
+ out.push("", `**Ordem de publicação dos requisitos** (coluna Requisito, de baixo para cima, para cada um nascer com os ids que cita): ${pub.map((sg) => `\`${sg}\``).join(" → ")}.`,
495
+ "Os tipos de Embute entram em embed (`embutir`) no texto de quem os cita; os demais links ficam hyperlink.", "");
496
+ return reescrever(out.join("\n"), path.join(m.fw, "skills/sabia/references/tipos.md"), ctx);
497
+ }
498
+
499
+ const comVersao = (fmYaml, versao) => (versao ? fmYaml.replace(/^(\s+version:\s*)"[^"]*"/m, `$1"${versao}"`) : fmYaml);
500
+
501
+ function copiarArvore(origem, destino, transformar) {
502
+ fs.mkdirSync(destino, { recursive: true });
503
+ for (const n of fs.readdirSync(origem)) {
504
+ if (n === "__pycache__" || n === ".DS_Store") continue;
505
+ const o = path.join(origem, n), d = path.join(destino, n);
506
+ if (fs.statSync(o).isDirectory()) copiarArvore(o, d, transformar);
507
+ else if (n.endsWith(".md")) fs.writeFileSync(d, transformar(ler(o), o));
508
+ else fs.copyFileSync(o, d);
509
+ }
510
+ }
511
+
512
+ /** Confere a saída: todo link para `base` resolve num arquivo do staging (e a âncora existe); nada do layout da fonte sobra. */
513
+ function conferirSaida(staging, base) {
514
+ const erros = [];
515
+ const raiz = path.join(staging, "skills");
516
+ const cache = {};
517
+ 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)] : []));
518
+ for (const f of andar(raiz)) {
519
+ const t = ler(f);
520
+ const rel = path.relative(staging, f);
521
+ t.split("\n").forEach((l, i) => {
522
+ if (/~\/\.sabia\/(framework\/(skills|fluxo|padrao)|fluxo|padrao)\//.test(l)) erros.push(`${rel}:${i + 1}: caminho da fonte na saída`);
523
+ for (const [, alvo] of l.matchAll(LINK)) {
524
+ if (!alvo.startsWith(base + "/")) continue;
525
+ const [cam, anc] = alvo.slice(base.length + 1).split("#");
526
+ const arq = path.join(raiz, cam);
527
+ if (!fs.existsSync(arq)) { erros.push(`${rel}:${i + 1}: link para ${cam} inexistente`); continue; }
528
+ if (anc && !(cache[arq] ||= ancoras(ler(arq))).has(anc)) erros.push(`${rel}:${i + 1}: âncora #${anc} não existe em ${cam}`);
529
+ }
530
+ });
531
+ }
532
+ return erros;
533
+ }
534
+
535
+ /** Uma skill compilada em `out`: a árvore com os links reescritos e, se tem fluxo, o SKILL.md, os passos e o grafo. */
536
+ function escreverSkill(m, s, out, ctx, versao = null) {
537
+ const { fw, base } = ctx, dir = path.join(fw, "skills", s);
538
+ copiarArvore(dir, out, (t, o) => reescrever(t, o, ctx));
539
+ const fonte = ler(path.join(dir, "SKILL.md"));
540
+ const fmYaml = (fonte.match(FM) || [""])[0];
541
+ if (fluxosDa(m, s).length) {
542
+ const h1 = (fonte.slice(fmYaml.length).match(/^# .+$/m) || [`# ${s}`])[0];
543
+ fs.writeFileSync(path.join(out, "SKILL.md"), skillMd(m, s, comVersao(fmYaml, versao), h1, ctx));
544
+ for (const fl of fluxosDa(m, s)) {
545
+ fs.mkdirSync(path.join(out, "references", fl.fluxo), { recursive: true });
546
+ fs.writeFileSync(path.join(out, "references", fl.fluxo, "grafo.md"), grafoMd(fl));
547
+ const linhaSprint = (x) => (x.de ? `Sprint deste fluxo: \`${x.sprint}\`. Vizinhos: a seção Entrada: vizinhos do [Fluxo ${fl.fluxo}](${base}/${s}/SKILL.md#${slug(`Fluxo ${fl.fluxo}`)}).\n\n` : "");
548
+ for (const x of fl.passos)
549
+ fs.writeFileSync(path.join(out, "references", fl.fluxo, `${x.id}.md`), italico(x.dados.resumo) + linhaSprint(x) + reescrever(x.corpo, x.arq, ctx).replace(/^\n+/, ""));
550
+ }
551
+ } else if (fmYaml) {
552
+ fs.writeFileSync(path.join(out, "SKILL.md"), comVersao(fmYaml, versao) + reescrever(fonte.slice(fmYaml.length), path.join(dir, "SKILL.md"), ctx));
553
+ }
554
+ for (const p of m.padroes[s] || []) {
555
+ fs.mkdirSync(path.join(out, "references", "padrao"), { recursive: true });
556
+ fs.writeFileSync(path.join(out, "references", "padrao", p.nome), italico(p.dados?.resumo) + reescrever(p.corpo, p.arq, ctx).replace(/^\n+/, ""));
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Compila o framework `fw` no `staging` (recriado do zero): staging/skills/<s>/… e, com `raiz`, plugin.json, mcp.json,
562
+ * README.md e LICENSE (o power do Kiro). `base` é o caminho absoluto com ~ onde as skills ficam no alvo.
563
+ */
564
+ export function compilar(fw, staging, { base, raiz = false, versao = null } = {}) {
565
+ const m = carregar(fw);
566
+ const erros = validar(m);
567
+ if (erros.length) throw new Error(`a fonte do Sabiá tem ${erros.length} erro(s):\n${erros.slice(0, 40).join("\n")}`);
568
+ fs.rmSync(staging, { recursive: true, force: true });
569
+ const ctx = { fw, base, m };
570
+ for (const s of skillsDe(fw)) escreverSkill(m, s, path.join(staging, "skills", s), ctx, versao);
571
+ if (Object.keys(m.tipos).length && fs.existsSync(path.join(staging, "skills/sabia"))) {
572
+ fs.mkdirSync(path.join(staging, "skills/sabia/references"), { recursive: true });
573
+ fs.writeFileSync(path.join(staging, "skills/sabia/references/tipos.md"), tiposMd(m, ctx));
574
+ }
575
+ 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));
576
+ const ruins = conferirSaida(staging, base);
577
+ if (ruins.length) throw new Error(`a skill compilada tem ${ruins.length} erro(s):\n${ruins.slice(0, 40).join("\n")}`);
578
+ return staging;
579
+ }
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-beta.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, grafoAscii, LEGENDA, partes, temDesvio, validarToolkitFonte } 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
 
@@ -193,13 +193,31 @@ export function separarRef(fonte) {
193
193
  * Baixa `url` (ou copia a pasta local) na `ref`, confere o `sha` e devolve { pasta, sha }. Clona com o git da
194
194
  * pessoa (credencial, SSH, CA), sem submódulos; nunca guarda token nem executa nada do que baixou.
195
195
  */
196
+ // as cópias baixadas são lidas e copiadas para ~/.sabia durante o comando; somem quando o processo termina
197
+ const temporarios = new Set();
198
+ process.on("exit", () => { for (const t of temporarios) fs.rmSync(t, { recursive: true, force: true }); });
199
+
196
200
  export function baixar(url, { ref = null, sha = null, subpasta = null } = {}) {
197
201
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sabia-"));
202
+ temporarios.add(tmp);
198
203
  const repo = path.join(tmp, "repo");
199
204
  let atual = null;
200
205
  if (fs.existsSync(url) && fs.statSync(url).isDirectory()) {
201
- fs.cpSync(url, repo, { recursive: true, filter: (f) => path.basename(f) !== ".git" });
202
- if (fs.existsSync(path.join(url, ".git"))) atual = git(["rev-parse", ref || "HEAD"], url);
206
+ const topo = ctx.exec("git", ["rev-parse", "--show-toplevel"], { cwd: url });
207
+ if (ref) {
208
+ // com @ref, a pasta vem do commit (git archive), não da árvore de trabalho; ref inexistente é recusada
209
+ if (topo.status !== 0) throw new Error(`${url}: @${ref} pede uma pasta dentro de um repositório git`);
210
+ atual = git(["rev-parse", "--verify", `${ref}^{commit}`], url);
211
+ const rel = path.relative(fs.realpathSync(topo.stdout.trim()), fs.realpathSync(url)).split(path.sep).join("/");
212
+ const tar = path.join(tmp, "a.tar");
213
+ git(["archive", "--format=tar", "-o", tar, rel ? `${atual}:${rel}` : atual], url);
214
+ fs.mkdirSync(repo);
215
+ const r = ctx.exec("tar", ["-xf", tar, "-C", repo]);
216
+ if (r.status !== 0) throw new Error(`tar: ${(r.stderr || "").trim()}`);
217
+ } else {
218
+ fs.cpSync(url, repo, { recursive: true, filter: (f) => path.basename(f) !== ".git" });
219
+ if (topo.status === 0) atual = git(["rev-parse", "HEAD"], url);
220
+ }
203
221
  } else {
204
222
  git(["clone", "--quiet", "--depth", "1", "--no-recurse-submodules", ...(ref ? ["--branch", ref] : []), url, repo]);
205
223
  atual = git(["rev-parse", "HEAD"], repo);
@@ -228,7 +246,7 @@ function temComando(cmd) {
228
246
  }
229
247
 
230
248
  export function detectarAlvos() {
231
- return ALVOS.filter((a) => temComando({ kiro: "kiro-cli", claude: "claude", opencode: "opencode" }[a]));
249
+ return ALVOS.filter((a) => temComando({ kiro: "kiro-cli", opencode: "opencode" }[a]));
232
250
  }
233
251
 
234
252
  async function escolherAlvos(opcoes) {
@@ -276,28 +294,31 @@ function tirarMcpDoOpencode(nome) {
276
294
  }
277
295
  }
278
296
 
279
- async function registrarMcps(alvo, fw, reg) {
297
+ /** Os MCPs do framework no opencode (no Kiro, vão no mcp.json do power). */
298
+ async function registrarMcps(fw, reg) {
280
299
  reg.mcp ||= [];
281
- const ja = alvo === "opencode" ? mcpsDoOpencode() : [];
300
+ const ja = mcpsDoOpencode();
282
301
  for (const [nome, s] of Object.entries(servidoresMcp(fw))) {
302
+ if (ja.includes(nome)) { ctx.log(`MCP ${nome} já existe no opencode — mantido`); continue; }
283
303
  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()}`);
304
+ const r = ctx.exec("opencode", ["mcp", "add", "--global", ...env.flatMap((e) => ["--env", e]), nome, "--", s.command, ...s.args]);
305
+ if (r.status !== 0) throw new Error(`MCP ${nome} no opencode: ${(r.stderr || r.error?.message || "").trim()}`);
294
306
  if (!reg.mcp.includes(nome)) reg.mcp.push(nome);
295
- ctx.log(`MCP ${nome} registrado no ${alvo}`);
307
+ ctx.log(`MCP ${nome} registrado no opencode`);
296
308
  }
297
309
  }
298
310
 
311
+ /** Onde as skills do Sabiá ficam no alvo (caminho com ~). */
312
+ const baseSabia = (alvo) => (alvo === "kiro" ? "~/.kiro/powers/installed/sabia/skills" : curto(PASTA_SKILLS[alvo]()));
313
+
314
+ /** Compila o framework para o alvo em ~/.sabia/compilado/<alvo>/. */
315
+ function compilarPara(alvo, fw) {
316
+ const versao = JSON.parse(fs.readFileSync(path.join(fw, "plugin.json"), "utf8")).version;
317
+ return compilar(fw, sabia("compilado", alvo), { base: baseSabia(alvo), raiz: alvo === "kiro", versao });
318
+ }
319
+
299
320
  async function instalarAlvo(alvo, inv, opcoes) {
300
- const fw = sabia("framework");
321
+ const fw = compilarPara(alvo, sabia("framework"));
301
322
  if (alvo === "kiro") {
302
323
  const nosso = inv.alvos.kiro?.power;
303
324
  // o painel Powers instala em ~/.kiro/powers/installed/sabia: fora do inventário, é de outra instalação
@@ -308,17 +329,11 @@ async function instalarAlvo(alvo, inv, opcoes) {
308
329
  if (r.status !== 0) throw new Error(`kiro-cli powers install: ${(r.stderr || r.error?.message || "").trim()}`);
309
330
  inv.alvos.kiro = { power: "sabia" };
310
331
  ctx.log("power sabia instalado no Kiro");
311
- if (fs.existsSync(path.join(home(), ".kiro", "powers", "installed", "sdd")))
312
- ctx.log("aviso: o power antigo sdd ainda está no Kiro e concorre com o sabia (desinstale pelo painel Powers se não usa mais)");
313
332
  return;
314
333
  }
315
334
  const reg = (inv.alvos[alvo] ||= { copias: {}, mcp: [] });
316
335
  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
- }
336
+ if (!opcoes.semMcp) await registrarMcps(sabia("framework"), reg);
322
337
  }
323
338
 
324
339
  /** Baixa o framework para uma pasta temporária e diz o que muda em relação a ~/.sabia/framework/. */
@@ -353,14 +368,14 @@ export async function instalar(opcoes = {}) {
353
368
  fs.rmSync(sabia("framework"), { recursive: true, force: true });
354
369
  fs.mkdirSync(sabia(), { recursive: true });
355
370
  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() };
371
+ inv.framework = { versao: novo.versao, sha: novo.sha, fonte: novo.fonte, compilador: VERSAO, instalado_em: new Date().toISOString() };
357
372
  ctx.log(`Sabiá ${novo.versao} em ~/.sabia/framework`);
358
373
  try {
359
374
  for (const alvo of await escolherAlvos(opcoes)) await instalarAlvo(alvo, inv, opcoes);
360
375
  } finally {
361
376
  gravarInventario(inv);
362
377
  }
363
- ctx.log('Pronto: skills e MCPs instalados. Os agentes são de cada projeto: abra a ferramenta na raiz dele, na sessão\n' +
378
+ ctx.log((opcoes.semMcp ? 'Pronto: skills instaladas (MCPs não registrados: --sem-mcp).' : 'Pronto: skills e MCPs instalados.') + ' Os agentes são de cada projeto: abra a ferramenta na raiz dele, na sessão\n' +
364
379
  'padrão, e peça "configurar o Sabiá" (ou, num projeto já configurado, "atualizar os agentes do Sabiá").');
365
380
  }
366
381
 
@@ -369,11 +384,14 @@ export async function atualizar(opcoes = {}) {
369
384
  if (!inv.framework) throw new Error("o Sabiá não está instalado: rode `sabia instalar`");
370
385
  const novo = baixarFramework({ fonte: inv.framework.fonte === REPO ? undefined : inv.framework.fonte, ...opcoes });
371
386
  ctx.log(`Sabiá ${inv.framework.versao} → ${novo.versao}`);
372
- if (!mostrarDiferencas(diferencas(sabia("framework"), novo.pasta))) return ctx.log("nada mudou");
387
+ // o compilador também conta: a mesma fonte compilada por outro CLI pode dar outra skill
388
+ const compilador = inv.framework.compilador === VERSAO ? null : `compilador ${inv.framework.compilador || "—"} → ${VERSAO}`;
389
+ if (!mostrarDiferencas(diferencas(sabia("framework"), novo.pasta)) && !compilador) return ctx.log("nada mudou");
390
+ if (compilador) ctx.log(` ${compilador}`);
373
391
  if (!/^s/i.test(await ctx.perguntar("Atualizar? [s/N]", "n"))) return ctx.log("nada foi alterado");
374
392
  fs.rmSync(sabia("framework"), { recursive: true, force: true });
375
393
  fs.cpSync(novo.pasta, sabia("framework"), { recursive: true });
376
- inv.framework = { ...inv.framework, versao: novo.versao, sha: novo.sha, instalado_em: new Date().toISOString() };
394
+ inv.framework = { ...inv.framework, versao: novo.versao, sha: novo.sha, compilador: VERSAO, instalado_em: new Date().toISOString() };
377
395
  try {
378
396
  for (const alvo of Object.keys(inv.alvos)) {
379
397
  if (alvo !== "kiro") { // skill que saiu do framework sai da ferramenta
@@ -399,18 +417,18 @@ export async function desinstalar(opcoes = {}) {
399
417
  const reg = inv.alvos[alvo];
400
418
  if (!reg) { ctx.log(`${alvo}: nada instalado pelo Sabiá`); continue; }
401
419
  for (const tk of Object.values(inv.toolkits)) if (tk.alvos?.[alvo]) { await apagar(tk.alvos[alvo].copias, opcoes); delete tk.alvos[alvo]; }
402
- if (alvo === "kiro") ctx.exec("kiro-cli", ["powers", "uninstall", reg.power]);
403
- 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
- }
420
+ if (alvo === "kiro") {
421
+ const r = ctx.exec("kiro-cli", ["powers", "uninstall", reg.power]);
422
+ if (r.status !== 0) { ctx.log(`kiro: kiro-cli powers uninstall falhou: ${(r.stderr || r.error?.message || "").trim()}`); continue; }
423
+ } else await apagar(reg.copias, opcoes);
424
+ if (alvo === "opencode") for (const nome of reg.mcp || []) tirarMcpDoOpencode(nome);
408
425
  if (alvo !== "kiro" && Object.keys(reg.copias).length) continue; // ficou cópia alterada à mão
409
426
  delete inv.alvos[alvo];
410
427
  ctx.log(`${alvo}: Sabiá desinstalado`);
411
428
  }
412
429
  if (!Object.keys(inv.alvos).length) {
413
430
  fs.rmSync(sabia("framework"), { recursive: true, force: true });
431
+ fs.rmSync(sabia("compilado"), { recursive: true, force: true });
414
432
  inv.framework = null;
415
433
  }
416
434
  } finally {
@@ -420,8 +438,13 @@ export async function desinstalar(opcoes = {}) {
420
438
 
421
439
  // ---------- toolkits ----------
422
440
 
441
+ /** O passo do framework (`m` do carregar) que o `passo` de um toolkit assume, pelo id — a mesma regra do gancho() do
442
+ * gerar_agentes.py. */
443
+ const ganchoDe = (m, ext) => Object.entries(m.fluxos).flatMap(([q, fl]) => fl.passos.map((x) => ({ id: `${q}.${x.id}`, x })))
444
+ .find(({ id }) => id === ext.passo);
445
+
423
446
  /** 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) {
447
+ export function validarToolkit(pasta, nomeEsperado = null, fw = null) {
425
448
  const f = path.join(pasta, "toolkit.json");
426
449
  if (!fs.existsSync(f)) return ["falta o toolkit.json"];
427
450
  let m;
@@ -440,9 +463,23 @@ export function validarToolkit(pasta, nomeEsperado = null) {
440
463
  try { if (!fs.existsSync(path.join(pasta, dentro(c, de)))) erros.push(`${de}: não existe ${c}`); } catch (e) { erros.push(e.message); }
441
464
  };
442
465
  const agentes = new Set((m.agentes || []).map((a) => a.nome));
466
+ // sem o framework (toolkits baixar antes do instalar), o gancho é conferido na setup
467
+ const ganchos = fw && fs.existsSync(path.join(fw, "fluxo")) ? carregar(fw) : null;
443
468
  for (const s of m.skills || []) {
444
469
  existe(s.caminho, "skills");
470
+ // a skill do toolkit não ocupa um nome do Sabiá: a cópia dela trocaria a do Sabiá sem aviso
471
+ const sk = path.basename(path.dirname(String(s.caminho)));
472
+ if (sk.startsWith("sa-") || sk === "sabia") erros.push(`skill ${s.caminho}: nome do Sabiá (sa-* ou sabia); use outro nome de pasta`);
445
473
  if (s.passo?.agente && !agentes.has(s.passo.agente)) erros.push(`passo de ${s.caminho}: agente ${s.passo.agente} não está em agentes`);
474
+ if (s.passo && !s.passo.passo) erros.push(`passo de ${s.caminho}: use o id do passo (passo.passo)`);
475
+ else if (s.passo && ganchos) {
476
+ const g = ganchoDe(ganchos, s.passo);
477
+ if (!g) erros.push(`passo de ${s.caminho}: ${s.passo.passo} não existe no Sabiá instalado`);
478
+ else if (!g.x.desvio) erros.push(`passo de ${s.caminho}: ${s.passo.passo} não aceita desvio de toolkit${g.x.tipos ? ` (o tipo ${g.x.tipos.join(", ")} vai por \`tipos\`)` : ""}`);
479
+ }
480
+ }
481
+ if (ganchos && (m.tipos || m.rubricas || m.requer)) {
482
+ try { erros.push(...validarToolkitFonte(fw, pasta)); } catch (e) { erros.push(e.message); }
446
483
  }
447
484
  for (const s of m.steering || []) existe(s.caminho, "steering");
448
485
  for (const a of m.agentes || []) {
@@ -485,28 +522,36 @@ function acharNoIndice(nome, versao, opcoes) {
485
522
 
486
523
  export async function baixarToolkit(alvo, opcoes = {}) {
487
524
  const ehUrl = /[:/]/.test(alvo) && !/^[a-z0-9-]+(@[\w.-]+)?$/.test(alvo);
488
- const [nomeOuUrl, versaoOuRef] = separarRef(alvo);
489
- const e = ehUrl ? { nome: null, url: nomeOuUrl, ref: versaoOuRef } : acharNoIndice(nomeOuUrl, versaoOuRef, opcoes);
525
+ // `<url>[@ref]#<subpasta>`: o toolkit numa subpasta de um repositório (monorepo de toolkits)
526
+ const [semSub, subpasta = null] = ehUrl ? alvo.split("#") : [alvo];
527
+ const [nomeOuUrl, versaoOuRef] = separarRef(semSub);
528
+ const e = ehUrl ? { nome: null, url: nomeOuUrl, ref: versaoOuRef, subpasta } : acharNoIndice(nomeOuUrl, versaoOuRef, opcoes);
490
529
  const { pasta, sha } = baixar(e.url, e);
491
- const erros = validarToolkit(pasta, e.nome);
530
+ const erros = validarToolkit(pasta, e.nome, sabia("framework"));
492
531
  if (erros.length) throw new Error(`toolkit recusado:\n- ${erros.join("\n- ")}`);
493
532
  const m = JSON.parse(fs.readFileSync(path.join(pasta, "toolkit.json"), "utf8"));
494
533
  const destino = sabia("toolkits", m.nome);
495
534
  const dif = diferencas(destino, pasta);
535
+ dif.removidos = dif.removidos.filter((f) => f !== ".origem.json"); // é nossa, regravada abaixo
536
+ let mudou = true;
496
537
  if (fs.existsSync(destino)) {
497
538
  ctx.log(`toolkit ${m.nome}: ${JSON.parse(fs.readFileSync(path.join(destino, "toolkit.json"), "utf8")).versao} → ${m.versao}`);
498
- if (!mostrarDiferencas(dif)) { ctx.log("nada mudou"); return m.nome; }
499
- if (!/^s/i.test(await ctx.perguntar("Substituir? [s/N]", "n"))) { ctx.log("nada foi alterado"); return null; }
500
- fs.rmSync(destino, { recursive: true, force: true });
539
+ mudou = mostrarDiferencas(dif);
540
+ if (!mudou) ctx.log("nada mudou");
541
+ else if (!/^s/i.test(await ctx.perguntar("Substituir? [s/N]", "n"))) { ctx.log("nada foi alterado"); return null; }
542
+ else fs.rmSync(destino, { recursive: true, force: true });
501
543
  }
502
- fs.mkdirSync(path.dirname(destino), { recursive: true });
503
- fs.cpSync(pasta, destino, { recursive: true });
504
- const origem = { indice: e.indice || null, url: e.url, ref: e.ref || null, sha, versao: m.versao };
544
+ if (mudou) {
545
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
546
+ fs.cpSync(pasta, destino, { recursive: true });
547
+ }
548
+ // mesmo sem mudar o conteúdo, a origem pode ter mudado (da pasta local para o índice, por exemplo)
549
+ const origem = { indice: e.indice || null, url: e.url, ref: e.ref || null, subpasta: e.indice ? null : e.subpasta || null, sha, versao: m.versao };
505
550
  fs.writeFileSync(path.join(destino, ".origem.json"), JSON.stringify(origem, null, 2) + "\n");
506
551
  const inv = lerInventario();
507
552
  inv.toolkits[m.nome] = { ...(inv.toolkits[m.nome] || { alvos: {} }), versao: m.versao, sha, fonte: e.url };
508
553
  gravarInventario(inv);
509
- ctx.log(`toolkit ${m.nome} ${m.versao} em ~/.sabia/toolkits/${m.nome}`);
554
+ if (mudou) ctx.log(`toolkit ${m.nome} ${m.versao} em ~/.sabia/toolkits/${m.nome}`);
510
555
  return m.nome;
511
556
  }
512
557
 
@@ -524,7 +569,8 @@ export async function instalarToolkit(alvo, opcoes = {}) {
524
569
  // mesmos nomes de skill e de agente: as duas no usuário se sobrescreveriam
525
570
  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
571
  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);
572
+ const doInv = Object.keys(inv.alvos);
573
+ const alvos = opcoes.alvo || !doInv.length ? await escolherAlvos(opcoes) : doInv;
528
574
  try {
529
575
  for (const a of alvos.filter((x) => x !== "kiro")) {
530
576
  const reg = (tk.alvos[a] ||= { copias: {} });
@@ -562,13 +608,18 @@ export async function removerToolkit(nome, opcoes = {}) {
562
608
  gravarInventario(inv);
563
609
  }
564
610
  ctx.log(`toolkit ${nome} removido`);
611
+ const f = path.join(process.cwd(), ".sabia", "config", "agentes.json");
612
+ if (fs.existsSync(f) && JSON.parse(fs.readFileSync(f, "utf8")).toolkits?.[nome])
613
+ ctx.log(`aviso: este projeto ainda usa o ${nome}; peça "atualizar os agentes do Sabiá" para desligá-lo`);
565
614
  }
566
615
 
567
616
  export async function atualizarToolkits(nome, opcoes = {}) {
568
617
  const inv = lerInventario();
569
618
  for (const n of nome ? [nome] : Object.keys(inv.toolkits)) {
570
- const origem = JSON.parse(fs.readFileSync(sabia("toolkits", n, ".origem.json"), "utf8"));
571
- const alvo = origem.indice ? n : `${origem.url}${origem.ref ? "@" + origem.ref : ""}`;
619
+ const fo = sabia("toolkits", n, ".origem.json");
620
+ if (!fs.existsSync(fo)) throw new Error(`toolkit ${n}: sem ~/.sabia/toolkits/${n}/.origem.json; baixe de novo: sabia toolkits baixar <nome|url>`);
621
+ const origem = JSON.parse(fs.readFileSync(fo, "utf8"));
622
+ const alvo = origem.indice ? n : `${origem.url}${origem.ref ? "@" + origem.ref : ""}${origem.subpasta ? "#" + origem.subpasta : ""}`;
572
623
  await instalarToolkit(alvo, { ...opcoes, atualizar: true, indice: origem.indice || opcoes.indice,
573
624
  alvo: Object.keys(inv.toolkits[n]?.alvos || {}).join(",") || "kiro" });
574
625
  }
@@ -618,26 +669,244 @@ export function listarToolkits(opcoes = {}) {
618
669
  return linhas;
619
670
  }
620
671
 
672
+ // ---------- rota (diagnóstico, para pessoas) ----------
673
+
674
+ const lerJson = (f, padrao) => (fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : padrao);
675
+ const lerFm = (f) => (fs.existsSync(f) ? partes(fs.readFileSync(f, "utf8")).dados || {} : null);
676
+
677
+ /** Glob do `passo.arquivos`: `**` atravessa `/`; `*` e `?` não. */
678
+ export function casaGlob(glob, arquivo) {
679
+ const rx = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/|\*\*|\*|\?/g, (t) => ({ "**/": "(?:.*/)?", "**": ".*", "*": "[^/]*", "?": "[^/]" })[t]);
680
+ return new RegExp(`^${rx}$`).test(arquivo);
681
+ }
682
+
683
+ /** Colunas alinhadas; a última sem preenchimento. */
684
+ const colunas = (linhas) => {
685
+ const w = [];
686
+ for (const l of linhas) l.slice(0, -1).forEach((c, i) => { w[i] = Math.max(w[i] || 0, c.length); });
687
+ return linhas.map((l) => l.map((c, i) => (i < l.length - 1 ? c.padEnd(w[i]) : c)).join(" ").trimEnd()).join("\n");
688
+ };
689
+
690
+ /** Framework instalado (ou --framework), projeto (ou --projeto) e os desvios ligados nele, como o gerador os resolve. */
691
+ function contextoRota(opcoes) {
692
+ const fw = path.resolve(opcoes.framework || sabia("framework"));
693
+ if (!fs.existsSync(path.join(fw, "fluxo"))) throw new Error(`${curto(fw)} não tem fluxo/: rode \`sabia instalar\` ou passe --framework`);
694
+ const projeto = path.resolve(opcoes.projeto || process.cwd());
695
+ const cfg = lerJson(path.join(projeto, ".sabia", "config", "agentes.json"), {});
696
+ const m = carregar(fw);
697
+ const dirTk = (nome) => { const pasta = cfg.toolkits?.[nome]?.pasta; return !pasta ? sabia("toolkits", nome) : pasta.startsWith("~/") ? longo(pasta) : path.resolve(projeto, pasta); };
698
+ // os tipos dos toolkits do projeto viram o desvio por tipo: a skill do toolkit, com o fluxo do tipo, se o traz
699
+ const tipos = [];
700
+ for (const nome of Object.keys(cfg.toolkits || {})) {
701
+ const man = lerJson(path.join(dirTk(nome), "toolkit.json"), null);
702
+ if (!man) continue;
703
+ for (const [sigla, q] of Object.entries(man.tipos || {})) {
704
+ if (typeof q?.skill !== "string") { tipos.push({ sigla, fluxo: "(inválido: o tipo no toolkit.json precisa de {\"skill\"}; o gerador recusa)", toolkit: nome, versao: man.versao }); continue; }
705
+ const sk = path.basename(path.dirname(q.skill));
706
+ tipos.push({ sigla, fluxo: q.fluxo ? `skill ${sk} (${q.fluxo})` : `skill ${sk}`, toolkit: nome, versao: man.versao });
707
+ }
708
+ }
709
+ const desvios = (cfg.passos || []).flatMap((e) => {
710
+ const dir = dirTk(e.toolkit);
711
+ const man = lerJson(path.join(dir, "toolkit.json"), null);
712
+ const ext = man?.skills?.find((x) => x.caminho === e.skill && x.passo)?.passo;
713
+ if (!ext) { ctx.log(`aviso: toolkit ${e.toolkit} sem passo em ${e.skill} (${curto(dir)})`); return []; }
714
+ if (!ext.passo) {
715
+ ctx.log(`aviso: toolkit ${e.toolkit}: o passo de ${e.skill} não tem o id: use o id do passo (passo.passo)`
716
+ + ` e reinstale o toolkit (\`sabia toolkits atualizar ${e.toolkit}\`) — ignorado`);
717
+ return [];
718
+ }
719
+ // como o gancho() do gerador: só passo do framework com "desvio": true (senão o gerador recusa e o prompt não o tem)
720
+ const g = ganchoDe(m, ext), passo = g?.x.desvio ? g.id : null;
721
+ if (!passo) {
722
+ ctx.log(`aviso: toolkit ${e.toolkit}: o passo ${ext.passo} não aceita desvio de toolkit (o gerador recusa) — ignorado`);
723
+ return [];
724
+ }
725
+ const agente = e.agente && ext.agente ? ext.agente : null;
726
+ const skills = agente ? (man.agentes || []).find((a) => a.nome === agente)?.skills || [] : [e.skill];
727
+ return [{ toolkit: e.toolkit, versao: man.versao, passo, para: ext.para, arquivos: ext.arquivos || [],
728
+ agente, skill: path.basename(path.dirname(e.skill)), skills: skills.map((x) => path.basename(path.dirname(x))) }];
729
+ });
730
+ return { fw, projeto, m, versao: lerJson(path.join(fw, "plugin.json"), {}).version || "?", desvios, tipos };
731
+ }
732
+
733
+ /** Os passos do fluxo `q` na ordem, com o que o projeto mudou: origem sabia | projeto (corpo trocado) | inserido | removido. */
734
+ function resolverFluxo(c, q) {
735
+ const fl = c.m.fluxos[q.replace("/", ".")];
736
+ if (!fl) throw new Error(`fluxo ${q} não existe em ${curto(c.fw)} (use skill.fluxo, ex.: sa-codificar.codificar)`);
737
+ const dir = path.join(c.projeto, ".sabia", "fluxo", fl.skill, fl.fluxo);
738
+ const ops = lerFm(path.join(dir, `${fl.fluxo}.md`)) || {};
739
+ const doProjeto = (id) => path.join(dir, "passos", `${id}.md`);
740
+ const passos = fl.passos.map((x) => {
741
+ const base = lerFm(doProjeto(x.id));
742
+ const origem = (ops.remover || []).includes(x.id) ? "removido" : base ? "projeto" : "sabia";
743
+ return { ...x, origem, base: base?.base, arq: base ? doProjeto(x.id) : x.arq };
744
+ });
745
+ let falta = [...(ops.inserir || [])];
746
+ while (falta.length) { // em qualquer ordem, inclusive depois de outro inserido
747
+ const prontos = falta.filter((o) => passos.some((x) => x.id === o.depois));
748
+ if (!prontos.length) { ctx.log(`aviso: ${q}: inserir ${falta.map((o) => `${o.id} depois de ${o.depois}`).join(", ")}: o 'depois' não existe`); break; }
749
+ for (const o of prontos) {
750
+ let i = passos.findIndex((x) => x.id === o.depois);
751
+ while (passos[i + 1]?.depois === o.depois) i++;
752
+ passos.splice(i + 1, 0, { id: o.id, depois: o.depois, origem: "inserido", arq: doProjeto(o.id) });
753
+ }
754
+ falta = falta.filter((o) => !prontos.includes(o));
755
+ }
756
+ return { fl, q: `${fl.skill}.${fl.fluxo}`, passos };
757
+ }
758
+
759
+ const travasDe = (fl, x) => [...new Set([...(fl.d.travas_gerais || []), ...(x?.travas || [])])].join(", ") || "—";
760
+
761
+ /** A tarefa `id` do tasks.md (`1`, `2.1`; `T-2` também): o texto do bloco dela (a linha e as de baixo, mais
762
+ * recuadas) e o que ela cita entre crases, sem espaço (os arquivos, classes e métodos). */
763
+ export function lerTarefa(arquivo, id) {
764
+ if (!fs.existsSync(arquivo)) throw new Error(`${arquivo} não existe`);
765
+ const norm = (t) => t.replace(/^T-?/i, "").split(".").map(Number).join(".");
766
+ const linhas = fs.readFileSync(arquivo, "utf8").split("\n");
767
+ let achado;
768
+ const i = linhas.findIndex((l) => (achado = l.match(/^\s*- \[.\]\*? (\d+(?:\.\d+)*)\.? /)) && norm(achado[1]) === norm(id));
769
+ if (i < 0) throw new Error(`${arquivo}: não há a tarefa ${id} (use o número dela, ex.: #2 ou #2.1)`);
770
+ let fim = i + 1;
771
+ // linha em branco entre as subtarefas não fecha o bloco; fecha a primeira linha com recuo igual ou menor
772
+ for (let k = i + 1; k < linhas.length; k++) {
773
+ const r = linhas[k].search(/\S/);
774
+ if (r < 0) continue;
775
+ if (r <= linhas[i].search(/\S/)) break;
776
+ fim = k + 1;
777
+ }
778
+ const texto = linhas.slice(i, fim).filter((l) => l.trim()).map((l) => l.trim().replace(/^- (?:\[.\]\*? )?/, "")).join(" · ");
779
+ return { id: achado[1], texto, arquivos: [...new Set([...texto.matchAll(/`([^`\s]+)`/g)].map((m) => m[1]))] };
780
+ }
781
+
782
+ /** `sabia rota <arquivo>` (ou `<tasks.md>#<tarefa>`, pelos arquivos que ela cita): o passo com gancho cujo desvio
783
+ * casa, o dono, o toolkit, o motivo e as travas. */
784
+ export function rota(arquivo, opcoes = {}) {
785
+ const c = contextoRota(opcoes);
786
+ const t = arquivo.match(/^(.+\.md)#(.+)$/), tarefa = t && lerTarefa(t[1], t[2]);
787
+ const relativo = (a, base) => {
788
+ const r = path.relative(c.projeto, path.resolve(base, a));
789
+ return (r.startsWith("..") ? a : r).split(path.sep).join("/"); // fora do projeto: o caminho como veio
790
+ };
791
+ // a tarefa cita os arquivos como no repositório: relativos à raiz do projeto
792
+ const alvos = tarefa ? tarefa.arquivos.map((a) => relativo(a, c.projeto)) : [relativo(arquivo, process.cwd())];
793
+ const ganchos = Object.entries(c.m.fluxos).flatMap(([q, fl]) => fl.passos.filter((x) => x.desvio).map((x) => `${q}.${x.id}`));
794
+ let casou = null;
795
+ achar: for (const d of c.desvios) for (const a of alvos) { const g = d.arquivos.find((x) => casaGlob(x, a)); if (g) { casou = { d, g, a }; break achar; } }
796
+ // sem casar: o passo dos desvios por arquivo do projeto (o de código), senão o primeiro gancho do framework
797
+ const pid = casou?.d.passo || c.desvios.find((d) => d.arquivos.length)?.passo || ganchos[0];
798
+ if (!pid) throw new Error(`${curto(c.fw)} não tem passo com desvio de toolkit`);
799
+ const { fl, passos } = resolverFluxo(c, pid.split(".").slice(0, 2).join("."));
800
+ const x = passos.find((y) => y.id === pid.split(".")[2]);
801
+ if (!x) throw new Error(`o desvio aponta para ${pid}, que não é passo do Sabiá instalado`);
802
+ const agentes = [`.opencode/agents/${fl.d.dono}.md`, `.kiro/agents/${fl.d.dono}.json`].filter((f) => fs.existsSync(path.join(c.projeto, f)));
803
+ const linhas = [
804
+ ...(tarefa ? [["tarefa", tarefa.id, tarefa.texto]] : []),
805
+ ["passo", pid, x.origem === "projeto" ? `${path.relative(c.projeto, x.arq)} (projeto)` : `${curto(x.arq)} (Sabiá ${c.versao})`],
806
+ ["dono", fl.d.dono, agentes.join(", ") || "agente não gerado neste projeto"],
807
+ ];
808
+ if (casou) linhas.push(["desvio", casou.d.agente || `skill ${casou.d.skill}`, `toolkit ${casou.d.toolkit} ${casou.d.versao}`],
809
+ ["motivo", `casou ${casou.g}${tarefa ? ` em ${casou.a}` : ""}`], ["skills", [fl.skill, ...casou.d.skills].join(", ")]);
810
+ else {
811
+ linhas.push(["desvio", "— fluxo padrão", "nenhum padrão casou"]);
812
+ 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"]);
813
+ }
814
+ linhas.push(["travas", travasDe(fl, x)]);
815
+ return colunas(linhas);
816
+ }
817
+
818
+ const sid = (t) => t.replace(/[^A-Za-z0-9_]/g, "_");
819
+ const rotulo = (t) => t.replace(/[;:]/g, ",").replace(/[`"#]/g, "");
820
+
821
+ /** `sabia rota --fluxo <skill.fluxo>`: a tabela resolvida (com o que o projeto mudou), o grafo ASCII e o diagrama de estados. */
822
+ export function rotaFluxo(q, opcoes = {}) {
823
+ const c = contextoRota(opcoes);
824
+ const r = resolverFluxo(c, q), { fl } = r;
825
+ const desvios = c.desvios.filter((d) => d.passo?.startsWith(`${r.q}.`));
826
+ // desvio por tipo: o tipo que o passo cria tem skill de toolkit no projeto
827
+ const porTipo = (x) => c.tipos.filter((t) => x.tipos?.includes(t.sigla));
828
+ const origem = (x) => ({ sabia: "Sabiá", projeto: `projeto${x.base ? ` (base ${x.base})` : ""}`,
829
+ inserido: `projeto, inserido depois de ${x.depois}`, removido: "removido no projeto" })[x.origem];
830
+ let n = 0;
831
+ const tabela = [["#", "passo", "origem", "chama", "travas", "saídas"], ...r.passos.map((x) => [
832
+ x.origem === "removido" ? "-" : String(++n), x.id + (temDesvio(x) ? " ⇢" : ""), origem(x), [].concat(x.chama || []).join(", ") || "—",
833
+ (x.travas || []).join(", ") || "—", Object.entries(x.saidas || {}).map(([k, v]) => `${k} → ${v.replace(/^→ /, "passa a ")}`).join("; ") || "—"])];
834
+ const cab = [["fluxo", r.q, `dono ${fl.d.dono}`, `Sabiá ${c.versao}`], ["travas", (fl.d.travas_gerais || []).join(", ") || "—"],
835
+ ...desvios.map((d) => ["desvio ⇢", `${d.passo.split(".")[2]}: ${d.arquivos.join(", ") || `"${d.para}"`}`, `→ ${d.agente || `skill ${d.skill}`}`, `toolkit ${d.toolkit} ${d.versao}`]),
836
+ ...r.passos.flatMap((x) => porTipo(x).map((t) => ["tipo ⇢", `${x.id}: ${t.sigla}`, `→ ${t.fluxo}`, `toolkit ${t.toolkit} ${t.versao}`]))];
837
+
838
+ const out = ["stateDiagram-v2"], declarados = new Set();
839
+ const estado = (id, nome = id) => {
840
+ const s = sid(id);
841
+ if (s !== nome && !declarados.has(s)) { declarados.add(s); out.push(` state "${nome}" as ${s}`); }
842
+ return s;
843
+ };
844
+ const ativos = r.passos.filter((x) => x.origem !== "removido");
845
+ ativos.forEach((x, i) => {
846
+ const a = estado(x.id), prox = ativos[i + 1];
847
+ if (!i) out.push(` [*] --> ${a}`);
848
+ for (const [cond, dest] of Object.entries(x.saidas || {})) {
849
+ if (dest === "fim") out.push(` ${a} --> [*]: ${rotulo(cond)}`);
850
+ else if (dest.startsWith("→ ")) { const q2 = dest.slice(2).trim(); out.push(` ${a} --> ${estado(`x_${q2}`, q2)}: ${rotulo(cond)} (passagem)`); }
851
+ else if (ativos.some((y) => y.id === dest)) out.push(` ${a} --> ${estado(dest)}: ${rotulo(cond)}`);
852
+ }
853
+ out.push(prox ? ` ${a} --> ${estado(prox.id)}${prox.origem === "inserido" ? ": projeto (inserido)" : ""}` : ` ${a} --> [*]`);
854
+ const nota = [x.chama && `chama ${[].concat(x.chama).join(", ")}`, x.origem === "projeto" && `corpo do projeto${x.base ? ` (base ${x.base})` : ""}`,
855
+ x.desvio && desvios.length && `desvio ⇢ ${desvios.filter((d) => d.passo === `${r.q}.${x.id}`).map((d) => d.agente || d.skill).join(", ")}`,
856
+ ...porTipo(x).map((t) => `tipo ${t.sigla} ⇢ ${t.fluxo}`)].filter(Boolean);
857
+ if (nota.length) out.push(` note right of ${a}: ${rotulo(nota.join(" · "))}`);
858
+ });
859
+ const notas = (x) => [x.origem !== "sabia" && `origem: ${origem(x)}`,
860
+ ...desvios.filter((d) => d.passo === `${r.q}.${x.id}`).map((d) => `⇢ desvio → ${d.agente || `skill ${d.skill}`} (toolkit ${d.toolkit} ${d.versao})`),
861
+ ...porTipo(x).map((t) => `⇢ tipo ${t.sigla} → ${t.fluxo} (toolkit ${t.toolkit} ${t.versao})`)].filter(Boolean);
862
+ return [colunas(cab), "", colunas(tabela), "", grafoAscii(fl, ativos, { notas }), "", LEGENDA, "", "```mermaid", ...out, "```"].join("\n");
863
+ }
864
+
865
+ /** `sabia rota --mapa`: as arestas entre fluxos de todo o framework (chama e passagens), em Mermaid. */
866
+ export function rotaMapa(opcoes = {}) {
867
+ const c = contextoRota(opcoes);
868
+ const out = ["flowchart LR"], nos = new Set(), vistas = new Set();
869
+ const no = (q) => { const s = sid(q); if (!nos.has(s)) { nos.add(s); out.push(` ${s}["${q}"]`); } return s; };
870
+ for (const [q, fl] of Object.entries(c.m.fluxos))
871
+ for (const x of fl.passos) {
872
+ const arestas = [...[].concat(x.chama || []).map((d) => [d, "-->"]),
873
+ ...Object.values(x.saidas || {}).filter((d) => d.startsWith("→ ")).map((d) => [d.slice(2).trim(), "-.->"])];
874
+ for (const [d, seta] of arestas) {
875
+ const q2 = d.split(".").slice(0, 2).join(".");
876
+ if (vistas.has(`${q} ${seta} ${q2} ${x.id}`)) continue;
877
+ vistas.add(`${q} ${seta} ${q2} ${x.id}`);
878
+ out.push(` ${no(q)} ${seta}|${x.id}| ${no(q2)}`);
879
+ }
880
+ }
881
+ return [`Fluxos do Sabiá ${c.versao}: --> chama (volta ao passo) · -.-> passagem (o fluxo termina e segue no outro); rótulo = passo de origem`,
882
+ "", "```mermaid", ...out, "```"].join("\n");
883
+ }
884
+
621
885
  // ---------- linha de comando ----------
622
886
 
623
887
  const AJUDA = `${BANNER}
624
888
 
625
- sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro, no Claude Code e no opencode
889
+ sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro e no opencode
626
890
 
627
- sabia instalar [--alvo kiro,claude,opencode] [--versao X] [--fonte <pasta|url-git>[@ref]]
628
- sabia atualizar [--versao X]
891
+ sabia instalar [--alvo kiro,opencode] [--versao X] [--fonte <pasta|url-git>[@ref]]
892
+ sabia atualizar [--versao X] [--fonte <pasta|url-git>[@ref]]
629
893
  sabia desinstalar [--alvo …]
630
894
  sabia toolkits listar [--indice <url-git|pasta>]
631
- sabia toolkits baixar <nome>[@versao] | <url-git>[@ref]
632
- sabia toolkits instalar <nome>[@versao] | <url-git>[@ref] [--alvo …] (sem argumento, no projeto: as versões dele)
633
- sabia toolkits atualizar [<nome>]
895
+ sabia toolkits baixar <nome>[@versao] | <url-git|pasta>[@ref][#subpasta] [--indice <url-git|pasta>]
896
+ sabia toolkits instalar <nome>[@versao] | <url-git|pasta>[@ref][#subpasta] [--alvo …] [--indice <url-git|pasta>] (sem argumento, no projeto: as versões dele)
897
+ sabia toolkits atualizar [<nome>] [--indice <url-git|pasta>]
634
898
  sabia toolkits remover <nome>
899
+ sabia rota <arquivo> o passo com desvio de toolkit, o dono, o toolkit, o motivo e as travas
900
+ sabia rota <tasks.md>#<tarefa> o mesmo pelos arquivos que a tarefa (#2, #2.1) cita; sem casar, o texto dela
901
+ sabia rota --fluxo <skill.fluxo> o fluxo resolvido no projeto (tabela, grafo ASCII e Mermaid)
902
+ sabia rota --mapa as arestas entre fluxos (chama e passagens), em Mermaid
903
+ [--projeto <pasta>] (padrão: a atual) [--framework <pasta>] (padrão: ~/.sabia/framework)
635
904
 
636
905
  --sim responde "sim" às confirmações (cópia alterada à mão continua perguntando, salvo --forcar)
637
906
  --forcar sobrescreve o que não foi instalado pelo Sabiá ou foi alterado à mão
638
907
  --sem-mcp não registra os MCPs
639
908
 
640
- Tudo fica em ~/.sabia/ (framework/, toolkits/, inventario.json).`;
909
+ Tudo fica em ~/.sabia/ (framework/, compilado/, toolkits/, inventario.json e indices.json).`;
641
910
 
642
911
  export function lerArgs(argv) {
643
912
  const posicionais = [];
@@ -646,14 +915,17 @@ export function lerArgs(argv) {
646
915
  const a = argv[i];
647
916
  if (!a.startsWith("--")) { posicionais.push(a); continue; }
648
917
  const chave = a.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase());
649
- if (["sim", "forcar", "semMcp", "ajuda", "help"].includes(chave)) opcoes[chave] = true;
918
+ if (["sim", "forcar", "semMcp", "mapa", "ajuda", "help"].includes(chave)) opcoes[chave] = true;
919
+ else if (argv[i + 1] === undefined || argv[i + 1].startsWith("--")) throw new Error(`--${a.slice(2)} precisa de um valor`);
650
920
  else opcoes[chave] = argv[++i];
651
921
  }
652
922
  return { posicionais, opcoes };
653
923
  }
654
924
 
655
925
  export async function main(argv = process.argv.slice(2)) {
656
- const { posicionais: [cmd, sub, arg], opcoes } = lerArgs(argv);
926
+ let args;
927
+ try { args = lerArgs(argv); } catch (e) { console.error(`sabia: ${e.message}`); return 1; }
928
+ const { posicionais: [cmd, sub, arg], opcoes } = args;
657
929
  if (opcoes.sim) { // cópia alterada à mão continua perguntando: só o --forcar passa por cima
658
930
  const antes = ctx.perguntar;
659
931
  ctx.perguntar = async (texto, padrao) => (/alterado à mão/.test(texto) ? antes(texto, padrao) : /\[s\/N\]/.test(texto) ? "s" : padrao);
@@ -667,6 +939,8 @@ export async function main(argv = process.argv.slice(2)) {
667
939
  else if (cmd === "toolkits" && sub === "instalar") await (arg ? instalarToolkit(arg, opcoes) : instalarDoProjeto(opcoes));
668
940
  else if (cmd === "toolkits" && sub === "atualizar") await atualizarToolkits(arg, opcoes);
669
941
  else if (cmd === "toolkits" && sub === "remover" && arg) await removerToolkit(arg, opcoes);
942
+ else if (cmd === "rota" && (sub || opcoes.fluxo || opcoes.mapa))
943
+ ctx.log(opcoes.mapa ? rotaMapa(opcoes) : opcoes.fluxo ? rotaFluxo(opcoes.fluxo, opcoes) : rota(sub, opcoes));
670
944
  else { ctx.log(AJUDA); return cmd && !opcoes.ajuda && !opcoes.help ? 1 : 0; }
671
945
  return 0;
672
946
  } catch (e) {