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.
- package/compilar.mjs +579 -0
- package/package.json +4 -4
- 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": "
|
|
4
|
-
"description": "Instala, atualiza e desinstala o Sabiá e os toolkits dele no Kiro
|
|
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
|
|
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", "
|
|
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
|
-
|
|
202
|
-
if (
|
|
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",
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
285
|
-
if (
|
|
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
|
|
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(
|
|
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
|
-
|
|
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")
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
489
|
-
const
|
|
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
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
|
571
|
-
|
|
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
|
|
889
|
+
sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro e no opencode
|
|
626
890
|
|
|
627
|
-
sabia instalar [--alvo kiro,
|
|
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
|
-
|
|
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) {
|