sabia 2.0.0-alpha.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/compilar.mjs +245 -36
  2. package/package.json +1 -1
  3. package/sabia.mjs +147 -59
package/compilar.mjs CHANGED
@@ -1,5 +1,5 @@
1
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.
2
+ // references/) numa pasta de staging, uma por alvo.
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
 
@@ -22,11 +22,29 @@ export function partes(texto) {
22
22
  export const slug = (titulo) => titulo.trim().toLowerCase().replace(/`/g, "").replace(/[^\p{L}\p{N} _-]/gu, "").replace(/ /g, "-");
23
23
  const ancoras = (texto) => new Set([...texto.matchAll(/^#+ (.+)$/gm)].map((m) => slug(m[1])));
24
24
 
25
- /** Lê a fonte inteira: fluxos, passos, regras, padrões, travas e papéis. */
25
+ export const VIZINHOS = "## Entrada: vizinhos";
26
+ export const SPRINT = ["exige", "nao"];
27
+ export const BRANCH = ["fatia", "nao"];
28
+
29
+ /** As linhas "- <estado> → <destino>" da seção Entrada: vizinhos do corpo do mestre (null sem a seção). */
30
+ function vizinhos(corpo, pulo) {
31
+ const ls = corpo.split("\n"), i = ls.findIndex((l) => l.trim() === VIZINHOS);
32
+ if (i < 0) return null;
33
+ const out = [];
34
+ for (let j = i + 1; j < ls.length && !/^#{1,6} /.test(ls[j]); j++) {
35
+ if (!ls[j].trim()) continue;
36
+ const t = ls[j].trim(), k = t.lastIndexOf(" → ");
37
+ out.push({ n: pulo + j + 1, ...(t.startsWith("- ") && k > 2 ? { estado: t.slice(2, k), destino: t.slice(k + 3) } : {}) });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Lê a fonte inteira: fluxos, passos, regras, padrões, travas, tipos e papéis. */
26
43
  export function carregar(fw) {
27
- const m = { fw, fluxos: {}, regras: {}, padroes: {}, travas: {}, subag: {}, skillsAgente: {} };
44
+ const m = { fw, fluxos: {}, regras: {}, padroes: {}, travas: {}, tipos: {}, subag: {}, skillsAgente: {} };
28
45
  const dirFluxo = path.join(fw, "fluxo");
29
46
  if (fs.existsSync(path.join(dirFluxo, "travas.json"))) m.travas = json(path.join(dirFluxo, "travas.json")).travas;
47
+ if (fs.existsSync(path.join(dirFluxo, "tipos.json"))) m.tipos = json(path.join(dirFluxo, "tipos.json")).tipos || {};
30
48
  const papeis = path.join(fw, "skills/sa-setup/assets/agents/papeis.json");
31
49
  if (fs.existsSync(papeis))
32
50
  for (const [k, v] of Object.entries(json(papeis).papeis)) {
@@ -42,18 +60,83 @@ export function carregar(fw) {
42
60
  if (!fs.existsSync(mestre)) continue;
43
61
  const p = partes(ler(mestre));
44
62
  const passos = (p.dados?.passos || []).map((x) => {
45
- const arq = path.join(ds, f, "passos", `${x.id}.md`);
63
+ // passo compartilhado ({"de": "entrada"}): o corpo vem de fluxo/<de>.md, não de passos/
64
+ const arq = x.de ? path.join(dirFluxo, `${x.de}.md`) : path.join(ds, f, "passos", `${x.id}.md`);
46
65
  return { ...x, arq, ...(fs.existsSync(arq) ? partes(ler(arq)) : { dados: null, corpo: "", pulo: 0 }) };
47
66
  });
48
- m.fluxos[`${s}.${f}`] = { skill: s, fluxo: f, arq: mestre, d: p.dados || {}, corpo: p.corpo, pulo: p.pulo, passos };
67
+ 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) };
49
68
  }
50
69
  }
51
70
  const dirPadrao = path.join(fw, "padrao");
52
71
  for (const s of fs.existsSync(dirPadrao) ? fs.readdirSync(dirPadrao).sort() : [])
53
72
  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))) }));
73
+ // os passos de "desviavel" são o ponto de desvio por tipo (⇢): a skill de um toolkit escreve esse artefato
74
+ for (const [sg, t] of Object.entries(m.tipos))
75
+ 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); }
54
76
  return m;
55
77
  }
56
78
 
79
+ /** a < b em versão semântica (os três números). */
80
+ function versaoMenor(a, b) {
81
+ const [x, y] = [a, b].map((v) => String(v).split(/[.-]/).slice(0, 3).map(Number));
82
+ const i = x.findIndex((n, k) => n !== y[k]);
83
+ return i >= 0 && x[i] < y[i];
84
+ }
85
+
86
+ /** O arquivo `f` está dentro da pasta `raiz` (caminhos relativos ou absolutos)? */
87
+ // como o dentro() do gerador: relativo, com /, sem ~, \\, unidade nem ..
88
+ const relativo = (c) => typeof c === "string" && !/^[/~]|\\|^[A-Za-z]:/.test(c) && !c.split("/").includes("..");
89
+ const sob = (raiz, f) => path.resolve(f).startsWith(path.resolve(raiz) + path.sep);
90
+
91
+ /**
92
+ * Erros do toolkit na pasta `tk` contra o Sabiá `fw`: requer, tipos (o tipo vai para uma skill do toolkit, com o fluxo e
93
+ * o padrão do tipo, arquivos da pasta lidos pelo caminho) e rubricas. O Sabiá não compila conteúdo de toolkit.
94
+ */
95
+ export function validarToolkitFonte(fw, tk) {
96
+ const m = carregar(fw), man = json(path.join(tk, "toolkit.json")), erros = [];
97
+ const versaoSabia = fs.existsSync(path.join(fw, "plugin.json")) ? json(path.join(fw, "plugin.json")).version : null;
98
+ if (man.requer?.sabia && versaoSabia && versaoMenor(versaoSabia, man.requer.sabia))
99
+ erros.push(`requer o Sabiá ${man.requer.sabia}; o instalado é o ${versaoSabia}`);
100
+ for (const [sg, q] of Object.entries(man.tipos || {})) {
101
+ const t = m.tipos[sg];
102
+ if (!t) erros.push(`tipo ${sg}: fora do catálogo do Sabiá (o toolkit não cria tipo)`);
103
+ else if (!t.desviavel?.length) erros.push(`tipo ${sg}: não é desviável no catálogo`);
104
+ else if (t.pendente) erros.push(`tipo ${sg}: pendente no Sabiá instalado (ainda sem o passo que o cria)`);
105
+ 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; }
106
+ const sk = (man.skills || []).find((s) => s.caminho === q.skill);
107
+ if (!sk) erros.push(`tipo ${sg}: a skill ${q.skill} não está em skills`);
108
+ // o gerador liga a skill ao dono de cada passo de desvio: sem o papel em "papeis", o toolkit não declara esse uso
109
+ else if (t?.desviavel) for (const c of t.desviavel) {
110
+ const papel = resolverId(m, c)?.fl.d.dono?.replace(/^sa-/, "");
111
+ 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})`);
112
+ }
113
+ for (const k of ["fluxo", "padrao"].filter((k) => k in q))
114
+ 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`);
115
+ }
116
+ const doSabia = new Set([...(m.padroes["sa-revisar"]?.find((p) => p.nome === "rubricas.md")?.corpo || "").matchAll(/^\| ([A-Z]\d+) \|/gm)].map((r) => r[1]));
117
+ const ids = (man.rubricas || []).map((r) => r.id);
118
+ for (const r of man.rubricas || []) {
119
+ if (!/^[A-Z]+\d+$/.test(r.id ?? "")) erros.push(`rubrica ${r.id ?? "?"}: id como EX1 (maiúsculas e número)`);
120
+ 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)`);
121
+ if (!String(r.verificacao ?? "").trim()) erros.push(`rubrica ${r.id ?? "?"}: sem verificacao`);
122
+ if (!["BLOQUEANTE", "IMPORTANTE", "SUGESTÃO"].includes(r.severidade)) erros.push(`rubrica ${r.id ?? "?"}: severidade "${r.severidade ?? ""}" (use BLOQUEANTE, IMPORTANTE ou SUGESTÃO)`);
123
+ }
124
+ return erros;
125
+ }
126
+
127
+ /** As siglas na ordem de publicação: cada uma depois das do seu "depois" (a ordem do catálogo desempata); null com ciclo. */
128
+ export function ordemTipos(tipos) {
129
+ const feitos = [], resto = Object.keys(tipos);
130
+ while (resto.length) {
131
+ const i = resto.findIndex((s) => (tipos[s].depois || []).every((d) => feitos.includes(d) || !tipos[d]));
132
+ if (i < 0) return null;
133
+ feitos.push(...resto.splice(i, 1));
134
+ }
135
+ return feitos;
136
+ }
137
+
138
+ const padraoExiste = (m, ref) => { const [s, n] = ref.split("/"); return !!m.padroes[s]?.some((p) => p.nome === n); };
139
+
57
140
  const fluxosDa = (m, s) => Object.values(m.fluxos).filter((f) => f.skill === s);
58
141
 
59
142
  /** "sa-x.f" ou "sa-x.f.p" existe? Devolve o fluxo e o passo (ou null). */
@@ -75,7 +158,7 @@ function modoChamada(m, dono, alvo) {
75
158
 
76
159
  // ---------------------------------------------------------------- validação
77
160
 
78
- /** Erros "arquivo:linha: mensagem" da fonte (as regras do conferidor da F2.0). Lista vazia = pronta para compilar. */
161
+ /** Erros "arquivo:linha: mensagem" da fonte. Lista vazia = pronta para compilar. */
79
162
  export function validar(m) {
80
163
  const erros = [];
81
164
  const rel = (f) => path.relative(m.fw, f);
@@ -93,7 +176,29 @@ export function validar(m) {
93
176
  ? mds(path.join(path.dirname(fl.arq), "passos")).map((f) => f.slice(0, -3)).filter((x) => !ids.includes(x)) : [];
94
177
  for (const o of orfaos) e(fl.arq, 2, `passo ${o} órfão (tem arquivo, não está no mestre)`);
95
178
  for (const t of d.travas_gerais || []) if (!m.travas[t]) e(fl.arq, 2, `trava ${t} fora do catálogo`);
179
+ if (fl.passos.some((x) => x.de) && !fl.vizinhos?.length)
180
+ e(fl.arq, 2, `passo entrada sem a seção "${VIZINHOS}" no corpo do mestre (uma linha "- <estado> → <dona>" por vizinho)`);
181
+ for (const v of fl.vizinhos || []) {
182
+ const alvos = v.destino ? [...v.destino.matchAll(ID)].map((r) => r[1]) : [];
183
+ if (!alvos.length && !v.destino?.endsWith("■")) { e(fl.arq, v.n, 'vizinho fora do formato "- <estado> → `sa-x.f[.p]`" ou "- <estado> → <frase> ■"'); continue; }
184
+ for (const q2 of alvos) {
185
+ const r = resolverId(m, q2);
186
+ if (!r) continue; // o id inexistente já sai na varredura do texto
187
+ if (r.fl === fl) e(fl.arq, v.n, `vizinho → ${q2}: é deste fluxo → declare como saída do passo entrada ("saidas" no mestre)`);
188
+ 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`);
189
+ 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})`);
190
+ 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`);
191
+ }
192
+ }
96
193
  for (const x of fl.passos) {
194
+ if (x.id === "grafo") e(fl.arq, 2, "passo grafo: id reservado (o compilador gera references/<fluxo>/grafo.md)");
195
+ if (x.id === "entrada" && !x.de) e(fl.arq, 2, 'passo entrada: id reservado ao passo compartilhado → {"id": "entrada", "de": "entrada", "sprint": "exige|nao", "branch": "fatia|nao"}');
196
+ if (x.de) {
197
+ if (!SPRINT.includes(x.sprint)) e(fl.arq, 2, `passo ${x.id}: sprint "${x.sprint ?? ""}" (use ${SPRINT.join(" ou ")})`);
198
+ if (!BRANCH.includes(x.branch)) e(fl.arq, 2, `passo ${x.id}: branch "${x.branch ?? ""}" (use ${BRANCH.join(" ou ")})`);
199
+ 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`);
200
+ if (!fs.existsSync(x.arq)) { e(fl.arq, 2, `passo ${x.id}: de "${x.de}" sem fluxo/${x.de}.md`); continue; }
201
+ }
97
202
  if (!x.dados) { e(x.arq, 1, `passo ${x.id} sem arquivo ou sem front-matter`); continue; }
98
203
  if (!x.dados.resumo) e(x.arq, 2, "passo sem resumo");
99
204
  for (const sec of ["## Entrada", "## Faça", "## Saída", "## Pare se"]) if (!x.corpo.includes(sec)) e(x.arq, 1, `falta ${sec}`);
@@ -104,12 +209,38 @@ export function validar(m) {
104
209
  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
210
  }
106
211
  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;
212
+ const r = dest.startsWith("→ ") ? resolverId(m, dest.slice(2).trim()) : null;
213
+ if (r?.passo && r.fl.d.dono !== d.dono)
214
+ 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})`);
215
+ if (dest === "fim" || ids.includes(dest) || r) continue;
108
216
  e(fl.arq, 2, `passo ${x.id}: saída "${cond}" → "${dest}" (use id do fluxo, fim ou "→ skill.fluxo[.passo]" existente)`);
109
217
  }
110
218
  }
111
219
  }
112
220
 
221
+ const arqTipos = path.join(m.fw, "fluxo", "tipos.json");
222
+ const rubrica = new Set([...(m.padroes["sa-revisar"]?.find((p) => p.nome === "rubricas.md")?.corpo || "").matchAll(/^\| ([A-Z]\d+) \|/gm)].map((r) => r[1]));
223
+ let forma = true;
224
+ for (const [sg, t] of Object.entries(m.tipos)) {
225
+ const naoLista = ["cria", "desviavel", "depois", "embute", "rubrica"].filter((k) => t[k] != null && !Array.isArray(t[k]));
226
+ for (const k of naoLista) e(arqTipos, 1, `tipo ${sg}: ${k} tem de ser lista`);
227
+ if (naoLista.length) { forma = false; continue; }
228
+ if (!/^[A-Z]{2,4}$/.test(sg) || !t.nome) e(arqTipos, 1, `tipo ${sg}: sigla de 2 a 4 maiúsculas e nome`);
229
+ // sigla única: o JSON.parse junta chaves repetidas; a conferência é no texto
230
+ else if ((ler(arqTipos).match(new RegExp(`"${sg}"\\s*:\\s*\\{`, "g")) || []).length > 1) e(arqTipos, 1, `tipo ${sg}: sigla repetida`);
231
+ if (!["DOORS", "ETM"].includes(t.elm)) e(arqTipos, 1, `tipo ${sg}: elm "${t.elm ?? ""}" (use DOORS ou ETM)`);
232
+ if (!t.cria?.length) e(arqTipos, 1, `tipo ${sg}: sem cria`);
233
+ for (const c of t.desviavel || []) if (!t.cria?.includes(c)) e(arqTipos, 1, `tipo ${sg}: desviavel ${c} fora de cria`);
234
+ // tipo pendente: declarado antes do padrão e do fluxo que o escrevem
235
+ 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`);
236
+ if (t.padrao && !t.pendente && !padraoExiste(m, t.padrao)) e(arqTipos, 1, `tipo ${sg}: padrão ${t.padrao} inexistente`);
237
+ for (const d of [...(t.depois || []), ...(t.embute || [])]) if (!m.tipos[d]) e(arqTipos, 1, `tipo ${sg}: ${d} fora do catálogo`);
238
+ for (const r of t.pendente ? [] : t.rubrica || []) if (!rubrica.has(r)) e(arqTipos, 1, `tipo ${sg}: rubrica ${r} fora de rubricas.md`);
239
+ if (t.pendente && t.cria?.length && t.cria.every((c) => resolverId(m, c)?.passo) && (!t.padrao || padraoExiste(m, t.padrao)))
240
+ e(arqTipos, 1, `tipo ${sg}: cria e padrão já existem; tire o "pendente"`);
241
+ }
242
+ if (forma && !ordemTipos(m.tipos)) e(arqTipos, 1, `ciclo no "depois" dos tipos`);
243
+
113
244
  const textos = [];
114
245
  for (const fl of Object.values(m.fluxos)) {
115
246
  textos.push({ arq: fl.arq, corpo: fl.corpo, pulo: fl.pulo, skill: fl.skill, fl });
@@ -161,7 +292,7 @@ export function validar(m) {
161
292
  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
293
  });
163
294
  }
164
- return erros;
295
+ return [...new Set(erros)]; // o passo compartilhado repete os mesmos erros em cada fluxo
165
296
  }
166
297
 
167
298
  // ---------------------------------------------------------------- reescrita
@@ -178,7 +309,8 @@ export function mapear(fw, src) {
178
309
  }
179
310
 
180
311
  /** 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 }) {
312
+ export function reescrever(texto, origem, ctx) {
313
+ const { fw, base, m } = ctx;
182
314
  const destino = (alvo) => {
183
315
  const [cam, anc = ""] = alvo.split("#");
184
316
  let src;
@@ -232,13 +364,57 @@ const destinoId = (base, r) => (r.passo ? `${base}/${r.fl.skill}/references/${r.
232
364
  const cel = (t) => String(t).replace(/\|/g, "\\|").replace(/\n/g, " ");
233
365
  const italico = (resumo) => (resumo ? `_${resumo}_\n\n` : "");
234
366
 
367
+ 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)";
368
+
369
+ /** O passo aceita desvio de toolkit: pelo para e arquivos ("desvio": true) ou pelo tipo do catálogo que ele cria. */
370
+ export const temDesvio = (x) => !!(x.desvio || x.tipos?.length);
371
+
372
+ /**
373
+ * O grafo ASCII do fluxo `fl` (de carregar): uma caixa por passo de `passos` (o rota passa os resolvidos no projeto) e, à
374
+ * direita, chama, travas e saídas. `notas(x)` acrescenta linhas no topo das anotações (origem, desvio ligado).
375
+ */
376
+ export function grafoAscii(fl, passos = fl.passos, { notas = () => [] } = {}) {
377
+ const ids = passos.map((x) => x.id), out = [];
378
+ const rot = (x, i) => `${i + 1} ${x.id}${x.sprint ? ` [${x.sprint}]` : ""}${temDesvio(x) ? " ⇢" : ""}`;
379
+ // 26 colunas cabem os ids do Sabiá; um id longo inserido pelo projeto alarga a caixa (par, por causa do ┬ central)
380
+ const W = 2 * Math.ceil(Math.max(26, ...passos.map((x, i) => rot(x, i).length + 4)) / 2);
381
+ const cond = (c) => (c.length > 60 ? c.slice(0, 57) + "…" : c);
382
+ out.push(`${fl.skill}.${fl.fluxo} dono ${fl.d.dono} travas ${(fl.d.travas_gerais || []).join(", ") || "—"}`, "");
383
+ passos.forEach((x, i) => {
384
+ const lado = [...notas(x)];
385
+ // os vizinhos numa linha só: a entrada diz a dona e para, não é passagem de trabalho (⇒)
386
+ const viz = x.de ? (fl.vizinhos || []).filter((v) => v.destino).map((v) => [...v.destino.matchAll(ID)][0]?.[1] || `■ ${cond(v.estado.replace(/`/g, ""))}`) : [];
387
+ if (viz.length) lado.push(`↪ vizinhos (diga e pare): ${[...new Set(viz)].join(" · ")}`);
388
+ if (x.tipos?.length) lado.push(`⇢ por tipo: ${x.tipos.join(", ")}`);
389
+ if (x.chama) lado.push(`⇄ chama ${[].concat(x.chama).join(", ")}`);
390
+ if (x.travas?.length) lado.push(`🔒 ${x.travas.join(", ")}`);
391
+ for (const [c, dest] of Object.entries(x.saidas || {})) {
392
+ const j = ids.indexOf(dest);
393
+ 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}`;
394
+ lado.push(`${seta} (${cond(c)})`);
395
+ }
396
+ const ultimo = i === passos.length - 1;
397
+ out.push(` ┌${"─".repeat(W - 2)}┐`);
398
+ out.push(` │ ${rot(x, i).padEnd(W - 4)} │${lado[0] ? `──▶ ${lado[0]}` : ""}`);
399
+ out.push((ultimo ? ` └${"─".repeat(W - 2)}┘` : ` └${"─".repeat(W / 2 - 2)}┬${"─".repeat(W / 2 - 1)}┘`) + (lado[1] ? ` ${lado[1]}` : ""));
400
+ for (const l of lado.slice(2)) out.push(`${" ".repeat(W + 6)}${l}`);
401
+ if (!ultimo) out.push(` ${" ".repeat(W / 2 - 1)}▼`);
402
+ });
403
+ return out.join("\n");
404
+ }
405
+
406
+ const grafoMd = (fl) => [`# Grafo de ${fl.skill}.${fl.fluxo}`, "", "```", grafoAscii(fl), "```", "", LEGENDA, ""].join("\n");
407
+
235
408
  const NO_PROJETO = `## No projeto
236
409
 
237
410
  Antes de ler o arquivo de um passo (\`…/skills/<skill>/references/<fluxo>/<passo>.md\`, de qualquer skill), tente abrir
238
411
  direto \`.sabia/fluxo/<skill>/<fluxo>/passos/<passo>.md\` na raiz do projeto: se existir, vale ele; senão, o da skill.
239
412
  O mesmo para um padrão: \`.sabia/padrao/<skill>/<nome>.md\` no lugar de \`…/references/padrao/<nome>.md\`. Abra pelo
240
413
  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.
414
+ e removidos nos fluxos de que ele é dono. Num passo ⇢, o desvio de toolkit sai pela skill dele: o agente dono a tem no
415
+ prompt; sem agente, valem a seção **Desvios de toolkit** (desvio por passo) e a **Tipos com skill de toolkit** (desvio por
416
+ 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
417
+ acrescenta à revisão) do \`.sabia/sabia.md\`.
242
418
  `;
243
419
 
244
420
  /** O SKILL.md compilado de uma skill de fluxo. */
@@ -257,8 +433,9 @@ function skillMd(m, s, fmYaml, h1, ctx) {
257
433
  const { titulo, resto } = semH1(fl.corpo);
258
434
  out.push(`## Fluxo ${fl.fluxo}`, "");
259
435
  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.", "");
436
+ out.push(`Agente: \`${fl.d.dono}\`. Sem ele, sugira à pessoa usá-lo; seguindo aqui, os passos inseridos e removidos pelo`,
437
+ "projeto não entram, e os desvios de toolkit (⇢) valem pelas seções **Desvios de toolkit** (desvio por passo) e",
438
+ "**Tipos com skill de toolkit** (desvio por tipo) do `.sabia/sabia.md`.", "");
262
439
  if (resto.trim()) out.push(rebaixar(reescrever(resto, fl.arq, ctx), 1).trimEnd(), "");
263
440
  out.push("| # | Passo | Arquivo | Chama | Travas | Saídas |", "|---|---|---|---|---|---|");
264
441
  (fl.d.travas_gerais || []).forEach((t) => usadas.add(t));
@@ -279,10 +456,11 @@ function skillMd(m, s, fmYaml, h1, ctx) {
279
456
  const alvo = destinoId(base, r);
280
457
  return `${cond} → passa a [\`${q}\`](${alvo})`;
281
458
  }).join("<br>") || "—";
282
- out.push(`| ${i + 1} | \`${x.id}\`${x.desvio ? " ⇢" : ""} | [${x.id}.md](${arq}) | ${cel(chama)} | ${cel((x.travas || []).join(", ") || "—")} | ${cel(saidas)} |`);
459
+ 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)} |`);
283
460
  });
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(", ") || "—"}.`, "");
461
+ out.push("", `Grafo: [grafo.md](${base}/${s}/references/${fl.fluxo}/grafo.md)`);
462
+ 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",
463
+ `próximo da lista; depois do último passo, o fluxo termina. Travas do fluxo: ${(fl.d.travas_gerais || []).map((t) => `\`${t}\``).join(", ") || "—"}.`, "");
286
464
  }
287
465
  const ordem = Object.keys(m.travas).filter((t) => usadas.has(t));
288
466
  if (ordem.length) out.push("## Travas", "", "Valem sobre o corpo de qualquer passo.", "", ...ordem.map((t) => `- **${t}** — ${m.travas[t].texto}`), "");
@@ -298,6 +476,29 @@ function skillMd(m, s, fmYaml, h1, ctx) {
298
476
  return out.join("\n");
299
477
  }
300
478
 
479
+ /** references/tipos.md da skill sabia: o catálogo de fluxo/tipos.json em tabela, com a ordem de publicação. */
480
+ function tiposMd(m, ctx) {
481
+ const { base } = ctx;
482
+ const ord = ordemTipos(m.tipos), sim = (b) => (b ? "sim" : "não"), lista = (xs) => (xs?.length ? xs.join(", ") : "—");
483
+ const padrao = (t) => !t.padrao ? "— (corpo no passo que cria)" : t.pendente ? `\`${t.padrao}\`` : `[\`${t.padrao}\`](${base}/${t.padrao.replace("/", "/references/padrao/")})`;
484
+ const out = ["# Tipos de artefato", "",
485
+ "Catálogo fechado: todo artefato da especificação e do ELM é de um destes tipos, e um toolkit não cria tipo. A coluna",
486
+ "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",
487
+ "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",
488
+ `é 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.`,
489
+ `Na coluna Arquivo, \`nn\` = 2 dígitos a partir de \`01\`, por tipo, em cada pasta \`elm/\`; na fatia, segue a numeração do épico (a cópia de um conceito do épico fica com o mesmo nome, e o novo continua a série); \`<slug>\` pela regra do \`backlog.md\` da \`sa-fatiar\` (seção Formato).`, "",
490
+ "| Sigla | Nome | Arquivo | Cria | Padrão | ELM | Requisito | Desviável | Publica depois de | Embute | Rubrica |",
491
+ "|---|---|---|---|---|---|---|---|---|---|---|"];
492
+ for (const sg of Object.keys(m.tipos)) {
493
+ const t = m.tipos[sg];
494
+ 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)} |`);
495
+ }
496
+ const pub = ord.filter((sg) => m.tipos[sg].requisito && !m.tipos[sg].pendente);
497
+ 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(" → ")}.`,
498
+ "Os tipos de Embute entram em embed (`embutir`) no texto de quem os cita; os demais links ficam hyperlink.", "");
499
+ return reescrever(out.join("\n"), path.join(m.fw, "skills/sabia/references/tipos.md"), ctx);
500
+ }
501
+
301
502
  const comVersao = (fmYaml, versao) => (versao ? fmYaml.replace(/^(\s+version:\s*)"[^"]*"/m, `$1"${versao}"`) : fmYaml);
302
503
 
303
504
  function copiarArvore(origem, destino, transformar) {
@@ -334,6 +535,31 @@ function conferirSaida(staging, base) {
334
535
  return erros;
335
536
  }
336
537
 
538
+ /** Uma skill compilada em `out`: a árvore com os links reescritos e, se tem fluxo, o SKILL.md, os passos e o grafo. */
539
+ function escreverSkill(m, s, out, ctx, versao = null) {
540
+ const { fw, base } = ctx, dir = path.join(fw, "skills", s);
541
+ copiarArvore(dir, out, (t, o) => reescrever(t, o, ctx));
542
+ const fonte = ler(path.join(dir, "SKILL.md"));
543
+ const fmYaml = (fonte.match(FM) || [""])[0];
544
+ if (fluxosDa(m, s).length) {
545
+ const h1 = (fonte.slice(fmYaml.length).match(/^# .+$/m) || [`# ${s}`])[0];
546
+ fs.writeFileSync(path.join(out, "SKILL.md"), skillMd(m, s, comVersao(fmYaml, versao), h1, ctx));
547
+ for (const fl of fluxosDa(m, s)) {
548
+ fs.mkdirSync(path.join(out, "references", fl.fluxo), { recursive: true });
549
+ fs.writeFileSync(path.join(out, "references", fl.fluxo, "grafo.md"), grafoMd(fl));
550
+ const linhaSprint = (x) => (x.de ? `Sprint deste fluxo: \`${x.sprint}\`. Branch deste fluxo: \`${x.branch}\`. Vizinhos: a seção Entrada: vizinhos do [Fluxo ${fl.fluxo}](${base}/${s}/SKILL.md#${slug(`Fluxo ${fl.fluxo}`)}).\n\n` : "");
551
+ for (const x of fl.passos)
552
+ 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+/, ""));
553
+ }
554
+ } else if (fmYaml) {
555
+ fs.writeFileSync(path.join(out, "SKILL.md"), comVersao(fmYaml, versao) + reescrever(fonte.slice(fmYaml.length), path.join(dir, "SKILL.md"), ctx));
556
+ }
557
+ for (const p of m.padroes[s] || []) {
558
+ fs.mkdirSync(path.join(out, "references", "padrao"), { recursive: true });
559
+ fs.writeFileSync(path.join(out, "references", "padrao", p.nome), italico(p.dados?.resumo) + reescrever(p.corpo, p.arq, ctx).replace(/^\n+/, ""));
560
+ }
561
+ }
562
+
337
563
  /**
338
564
  * Compila o framework `fw` no `staging` (recriado do zero): staging/skills/<s>/… e, com `raiz`, plugin.json, mcp.json,
339
565
  * README.md e LICENSE (o power do Kiro). `base` é o caminho absoluto com ~ onde as skills ficam no alvo.
@@ -344,27 +570,10 @@ export function compilar(fw, staging, { base, raiz = false, versao = null } = {}
344
570
  if (erros.length) throw new Error(`a fonte do Sabiá tem ${erros.length} erro(s):\n${erros.slice(0, 40).join("\n")}`);
345
571
  fs.rmSync(staging, { recursive: true, force: true });
346
572
  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
- }
573
+ for (const s of skillsDe(fw)) escreverSkill(m, s, path.join(staging, "skills", s), ctx, versao);
574
+ if (Object.keys(m.tipos).length && fs.existsSync(path.join(staging, "skills/sabia"))) {
575
+ fs.mkdirSync(path.join(staging, "skills/sabia/references"), { recursive: true });
576
+ fs.writeFileSync(path.join(staging, "skills/sabia/references/tipos.md"), tiposMd(m, ctx));
368
577
  }
369
578
  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
579
  const ruins = conferirSaida(staging, base);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sabia",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0",
4
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": {
package/sabia.mjs CHANGED
@@ -10,7 +10,7 @@ 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
+ import { carregar, compilar, grafoAscii, LEGENDA, partes, temDesvio, validarToolkitFonte } from "./compilar.mjs";
14
14
 
15
15
  const VERSAO = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
16
16
  const REPO = "https://github.com/dxaraujo/sabia.git";
@@ -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);
@@ -290,11 +308,13 @@ async function registrarMcps(fw, reg) {
290
308
  }
291
309
  }
292
310
 
293
- /** Compila o framework para o alvo em ~/.sabia/compilado/<alvo>/ (seção 11 do plano da 2.0). */
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>/. */
294
315
  function compilarPara(alvo, fw) {
295
316
  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 });
317
+ return compilar(fw, sabia("compilado", alvo), { base: baseSabia(alvo), raiz: alvo === "kiro", versao });
298
318
  }
299
319
 
300
320
  async function instalarAlvo(alvo, inv, opcoes) {
@@ -309,15 +329,11 @@ async function instalarAlvo(alvo, inv, opcoes) {
309
329
  if (r.status !== 0) throw new Error(`kiro-cli powers install: ${(r.stderr || r.error?.message || "").trim()}`);
310
330
  inv.alvos.kiro = { power: "sabia" };
311
331
  ctx.log("power sabia instalado no Kiro");
312
- if (fs.existsSync(path.join(home(), ".kiro", "powers", "installed", "sdd")))
313
- 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)");
314
332
  return;
315
333
  }
316
334
  const reg = (inv.alvos[alvo] ||= { copias: {}, mcp: [] });
317
335
  for (const s of skillsDoFluxo(fw)) await copiar(path.join(fw, "skills", s), path.join(PASTA_SKILLS[alvo](), s), reg.copias, opcoes);
318
336
  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)`);
321
337
  }
322
338
 
323
339
  /** Baixa o framework para uma pasta temporária e diz o que muda em relação a ~/.sabia/framework/. */
@@ -359,7 +375,7 @@ export async function instalar(opcoes = {}) {
359
375
  } finally {
360
376
  gravarInventario(inv);
361
377
  }
362
- 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' +
363
379
  'padrão, e peça "configurar o Sabiá" (ou, num projeto já configurado, "atualizar os agentes do Sabiá").');
364
380
  }
365
381
 
@@ -378,8 +394,6 @@ export async function atualizar(opcoes = {}) {
378
394
  inv.framework = { ...inv.framework, versao: novo.versao, sha: novo.sha, compilador: VERSAO, instalado_em: new Date().toISOString() };
379
395
  try {
380
396
  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; }
383
397
  if (alvo !== "kiro") { // skill que saiu do framework sai da ferramenta
384
398
  const copias = inv.alvos[alvo].copias;
385
399
  const fica = new Set(skillsDoFluxo(sabia("framework")).map((s) => curto(path.join(PASTA_SKILLS[alvo](), s))));
@@ -403,10 +417,11 @@ export async function desinstalar(opcoes = {}) {
403
417
  const reg = inv.alvos[alvo];
404
418
  if (!reg) { ctx.log(`${alvo}: nada instalado pelo Sabiá`); continue; }
405
419
  for (const tk of Object.values(inv.toolkits)) if (tk.alvos?.[alvo]) { await apagar(tk.alvos[alvo].copias, opcoes); delete tk.alvos[alvo]; }
406
- if (alvo === "kiro") ctx.exec("kiro-cli", ["powers", "uninstall", reg.power]);
407
- else await apagar(reg.copias, opcoes);
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);
408
424
  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`);
410
425
  if (alvo !== "kiro" && Object.keys(reg.copias).length) continue; // ficou cópia alterada à mão
411
426
  delete inv.alvos[alvo];
412
427
  ctx.log(`${alvo}: Sabiá desinstalado`);
@@ -423,6 +438,11 @@ export async function desinstalar(opcoes = {}) {
423
438
 
424
439
  // ---------- toolkits ----------
425
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
+
426
446
  /** Erros do toolkit.json: campos, nome, caminhos presos à pasta e arquivos que existem (o schema completo é o da setup). */
427
447
  export function validarToolkit(pasta, nomeEsperado = null, fw = null) {
428
448
  const f = path.join(pasta, "toolkit.json");
@@ -447,14 +467,20 @@ export function validarToolkit(pasta, nomeEsperado = null, fw = null) {
447
467
  const ganchos = fw && fs.existsSync(path.join(fw, "fluxo")) ? carregar(fw) : null;
448
468
  for (const s of m.skills || []) {
449
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`);
450
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`);
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`);
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\`)` : ""}`);
456
479
  }
457
480
  }
481
+ if (ganchos && (m.tipos || m.rubricas || m.requer)) {
482
+ try { erros.push(...validarToolkitFonte(fw, pasta)); } catch (e) { erros.push(e.message); }
483
+ }
458
484
  for (const s of m.steering || []) existe(s.caminho, "steering");
459
485
  for (const a of m.agentes || []) {
460
486
  if (!nomeOk(a.nome)) erros.push(`agente com nome inválido (kebab-case, sem sa-): ${a.nome}`);
@@ -496,28 +522,36 @@ function acharNoIndice(nome, versao, opcoes) {
496
522
 
497
523
  export async function baixarToolkit(alvo, opcoes = {}) {
498
524
  const ehUrl = /[:/]/.test(alvo) && !/^[a-z0-9-]+(@[\w.-]+)?$/.test(alvo);
499
- const [nomeOuUrl, versaoOuRef] = separarRef(alvo);
500
- 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);
501
529
  const { pasta, sha } = baixar(e.url, e);
502
530
  const erros = validarToolkit(pasta, e.nome, sabia("framework"));
503
531
  if (erros.length) throw new Error(`toolkit recusado:\n- ${erros.join("\n- ")}`);
504
532
  const m = JSON.parse(fs.readFileSync(path.join(pasta, "toolkit.json"), "utf8"));
505
533
  const destino = sabia("toolkits", m.nome);
506
534
  const dif = diferencas(destino, pasta);
535
+ dif.removidos = dif.removidos.filter((f) => f !== ".origem.json"); // é nossa, regravada abaixo
536
+ let mudou = true;
507
537
  if (fs.existsSync(destino)) {
508
538
  ctx.log(`toolkit ${m.nome}: ${JSON.parse(fs.readFileSync(path.join(destino, "toolkit.json"), "utf8")).versao} → ${m.versao}`);
509
- if (!mostrarDiferencas(dif)) { ctx.log("nada mudou"); return m.nome; }
510
- if (!/^s/i.test(await ctx.perguntar("Substituir? [s/N]", "n"))) { ctx.log("nada foi alterado"); return null; }
511
- 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 });
512
543
  }
513
- fs.mkdirSync(path.dirname(destino), { recursive: true });
514
- fs.cpSync(pasta, destino, { recursive: true });
515
- 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 };
516
550
  fs.writeFileSync(path.join(destino, ".origem.json"), JSON.stringify(origem, null, 2) + "\n");
517
551
  const inv = lerInventario();
518
552
  inv.toolkits[m.nome] = { ...(inv.toolkits[m.nome] || { alvos: {} }), versao: m.versao, sha, fonte: e.url };
519
553
  gravarInventario(inv);
520
- 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}`);
521
555
  return m.nome;
522
556
  }
523
557
 
@@ -535,7 +569,7 @@ export async function instalarToolkit(alvo, opcoes = {}) {
535
569
  // mesmos nomes de skill e de agente: as duas no usuário se sobrescreveriam
536
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}`);
537
571
  const tk = (inv.toolkits[baixado] ||= { versao: m.versao, alvos: {} });
538
- const doInv = Object.keys(inv.alvos).filter((a) => ALVOS.includes(a));
572
+ const doInv = Object.keys(inv.alvos);
539
573
  const alvos = opcoes.alvo || !doInv.length ? await escolherAlvos(opcoes) : doInv;
540
574
  try {
541
575
  for (const a of alvos.filter((x) => x !== "kiro")) {
@@ -574,15 +608,20 @@ export async function removerToolkit(nome, opcoes = {}) {
574
608
  gravarInventario(inv);
575
609
  }
576
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`);
577
614
  }
578
615
 
579
616
  export async function atualizarToolkits(nome, opcoes = {}) {
580
617
  const inv = lerInventario();
581
618
  for (const n of nome ? [nome] : Object.keys(inv.toolkits)) {
582
- const origem = JSON.parse(fs.readFileSync(sabia("toolkits", n, ".origem.json"), "utf8"));
583
- 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 : ""}`;
584
623
  await instalarToolkit(alvo, { ...opcoes, atualizar: true, indice: origem.indice || opcoes.indice,
585
- alvo: Object.keys(inv.toolkits[n]?.alvos || {}).filter((a) => ALVOS.includes(a)).join(",") || "kiro" });
624
+ alvo: Object.keys(inv.toolkits[n]?.alvos || {}).join(",") || "kiro" });
586
625
  }
587
626
  }
588
627
 
@@ -632,8 +671,6 @@ export function listarToolkits(opcoes = {}) {
632
671
 
633
672
  // ---------- rota (diagnóstico, para pessoas) ----------
634
673
 
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
674
  const lerJson = (f, padrao) => (fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : padrao);
638
675
  const lerFm = (f) => (fs.existsSync(f) ? partes(fs.readFileSync(f, "utf8")).dados || {} : null);
639
676
 
@@ -657,16 +694,32 @@ function contextoRota(opcoes) {
657
694
  const projeto = path.resolve(opcoes.projeto || process.cwd());
658
695
  const cfg = lerJson(path.join(projeto, ".sabia", "config", "agentes.json"), {});
659
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
+ }
660
709
  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);
710
+ const dir = dirTk(e.toolkit);
663
711
  const man = lerJson(path.join(dir, "toolkit.json"), null);
664
712
  const ext = man?.skills?.find((x) => x.caminho === e.skill && x.passo)?.passo;
665
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
+ }
666
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)
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`);
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`);
670
723
  return [];
671
724
  }
672
725
  const agente = e.agente && ext.agente ? ext.agente : null;
@@ -674,7 +727,7 @@ function contextoRota(opcoes) {
674
727
  return [{ toolkit: e.toolkit, versao: man.versao, passo, para: ext.para, arquivos: ext.arquivos || [],
675
728
  agente, skill: path.basename(path.dirname(e.skill)), skills: skills.map((x) => path.basename(path.dirname(x))) }];
676
729
  });
677
- return { fw, projeto, m, versao: lerJson(path.join(fw, "plugin.json"), {}).version || "?", desvios };
730
+ return { fw, projeto, m, versao: lerJson(path.join(fw, "plugin.json"), {}).version || "?", desvios, tipos };
678
731
  }
679
732
 
680
733
  /** Os passos do fluxo `q` na ordem, com o que o projeto mudou: origem sabia | projeto (corpo trocado) | inserido | removido. */
@@ -705,15 +758,41 @@ function resolverFluxo(c, q) {
705
758
 
706
759
  const travasDe = (fl, x) => [...new Set([...(fl.d.travas_gerais || []), ...(x?.travas || [])])].join(", ") || "—";
707
760
 
708
- /** `sabia rota <arquivo>`: o passo com gancho cujo desvio casa com o arquivo, o dono, o toolkit, o motivo e as travas. */
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. */
709
784
  export function rota(arquivo, opcoes = {}) {
710
785
  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("/");
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())];
714
793
  const ganchos = Object.entries(c.m.fluxos).flatMap(([q, fl]) => fl.passos.filter((x) => x.desvio).map((x) => `${q}.${x.id}`));
715
794
  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; } }
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; } }
717
796
  // sem casar: o passo dos desvios por arquivo do projeto (o de código), senão o primeiro gancho do framework
718
797
  const pid = casou?.d.passo || c.desvios.find((d) => d.arquivos.length)?.passo || ganchos[0];
719
798
  if (!pid) throw new Error(`${curto(c.fw)} não tem passo com desvio de toolkit`);
@@ -722,11 +801,12 @@ export function rota(arquivo, opcoes = {}) {
722
801
  if (!x) throw new Error(`o desvio aponta para ${pid}, que não é passo do Sabiá instalado`);
723
802
  const agentes = [`.opencode/agents/${fl.d.dono}.md`, `.kiro/agents/${fl.d.dono}.json`].filter((f) => fs.existsSync(path.join(c.projeto, f)));
724
803
  const linhas = [
804
+ ...(tarefa ? [["tarefa", tarefa.id, tarefa.texto]] : []),
725
805
  ["passo", pid, x.origem === "projeto" ? `${path.relative(c.projeto, x.arq)} (projeto)` : `${curto(x.arq)} (Sabiá ${c.versao})`],
726
806
  ["dono", fl.d.dono, agentes.join(", ") || "agente não gerado neste projeto"],
727
807
  ];
728
808
  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(", ")]);
809
+ ["motivo", `casou ${casou.g}${tarefa ? ` em ${casou.a}` : ""}`], ["skills", [fl.skill, ...casou.d.skills].join(", ")]);
730
810
  else {
731
811
  linhas.push(["desvio", "— fluxo padrão", "nenhum padrão casou"]);
732
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"]);
@@ -738,19 +818,22 @@ export function rota(arquivo, opcoes = {}) {
738
818
  const sid = (t) => t.replace(/[^A-Za-z0-9_]/g, "_");
739
819
  const rotulo = (t) => t.replace(/[;:]/g, ",").replace(/[`"#]/g, "");
740
820
 
741
- /** `sabia rota --fluxo <skill.fluxo>`: a tabela resolvida (com o que o projeto mudou) e o diagrama de estados. */
821
+ /** `sabia rota --fluxo <skill.fluxo>`: a tabela resolvida (com o que o projeto mudou), o grafo ASCII e o diagrama de estados. */
742
822
  export function rotaFluxo(q, opcoes = {}) {
743
823
  const c = contextoRota(opcoes);
744
824
  const r = resolverFluxo(c, q), { fl } = r;
745
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));
746
828
  const origem = (x) => ({ sabia: "Sabiá", projeto: `projeto${x.base ? ` (base ${x.base})` : ""}`,
747
829
  inserido: `projeto, inserido depois de ${x.depois}`, removido: "removido no projeto" })[x.origem];
748
830
  let n = 0;
749
831
  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(", ") || "—",
832
+ x.origem === "removido" ? "-" : String(++n), x.id + (temDesvio(x) ? " ⇢" : ""), origem(x), [].concat(x.chama || []).join(", ") || "—",
751
833
  (x.travas || []).join(", ") || "—", Object.entries(x.saidas || {}).map(([k, v]) => `${k} → ${v.replace(/^→ /, "passa a ")}`).join("; ") || "—"])];
752
834
  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}`])];
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}`]))];
754
837
 
755
838
  const out = ["stateDiagram-v2"], declarados = new Set();
756
839
  const estado = (id, nome = id) => {
@@ -769,10 +852,14 @@ export function rotaFluxo(q, opcoes = {}) {
769
852
  }
770
853
  out.push(prox ? ` ${a} --> ${estado(prox.id)}${prox.origem === "inserido" ? ": projeto (inserido)" : ""}` : ` ${a} --> [*]`);
771
854
  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);
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);
773
857
  if (nota.length) out.push(` note right of ${a}: ${rotulo(nota.join(" · "))}`);
774
858
  });
775
- return [colunas(cab), "", colunas(tabela), "", "```mermaid", ...out, "```"].join("\n");
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");
776
863
  }
777
864
 
778
865
  /** `sabia rota --mapa`: as arestas entre fluxos de todo o framework (chama e passagens), em Mermaid. */
@@ -802,15 +889,16 @@ const AJUDA = `${BANNER}
802
889
  sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro e no opencode
803
890
 
804
891
  sabia instalar [--alvo kiro,opencode] [--versao X] [--fonte <pasta|url-git>[@ref]]
805
- sabia atualizar [--versao X]
892
+ sabia atualizar [--versao X] [--fonte <pasta|url-git>[@ref]]
806
893
  sabia desinstalar [--alvo …]
807
894
  sabia toolkits listar [--indice <url-git|pasta>]
808
- sabia toolkits baixar <nome>[@versao] | <url-git>[@ref]
809
- sabia toolkits instalar <nome>[@versao] | <url-git>[@ref] [--alvo …] (sem argumento, no projeto: as versões dele)
810
- 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>]
811
898
  sabia toolkits remover <nome>
812
899
  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)
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)
814
902
  sabia rota --mapa as arestas entre fluxos (chama e passagens), em Mermaid
815
903
  [--projeto <pasta>] (padrão: a atual) [--framework <pasta>] (padrão: ~/.sabia/framework)
816
904
 
@@ -818,7 +906,7 @@ sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro e no opencode
818
906
  --forcar sobrescreve o que não foi instalado pelo Sabiá ou foi alterado à mão
819
907
  --sem-mcp não registra os MCPs
820
908
 
821
- Tudo fica em ~/.sabia/ (framework/, toolkits/, inventario.json).`;
909
+ Tudo fica em ~/.sabia/ (framework/, compilado/, toolkits/, inventario.json e indices.json).`;
822
910
 
823
911
  export function lerArgs(argv) {
824
912
  const posicionais = [];