dd-harness-mcp 0.1.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.
- package/dist/cli/src/api.js +69 -0
- package/dist/cli/src/artefato.js +42 -0
- package/dist/cli/src/buscar.js +15 -0
- package/dist/cli/src/check.js +114 -0
- package/dist/cli/src/config.js +81 -0
- package/dist/cli/src/curar.js +77 -0
- package/dist/cli/src/diff.js +52 -0
- package/dist/cli/src/gravar.js +105 -0
- package/dist/cli/src/index.js +381 -0
- package/dist/cli/src/init.js +84 -0
- package/dist/cli/src/materializa.js +132 -0
- package/dist/cli/src/medir.js +87 -0
- package/dist/cli/src/pasta.js +30 -0
- package/dist/cli/src/projeto.js +39 -0
- package/dist/cli/src/sync.js +153 -0
- package/dist/mcp/src/index.js +265 -0
- package/package.json +41 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { leConfigDoRepo, leToken } from "./config.js";
|
|
2
|
+
export async function credencial(raiz) {
|
|
3
|
+
const config = await leConfigDoRepo(raiz);
|
|
4
|
+
const token = await leToken(config.api);
|
|
5
|
+
if (!token) {
|
|
6
|
+
throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
|
|
7
|
+
}
|
|
8
|
+
return { config, token };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* O `erro` que a API devolve no corpo e mais util que o status, e e o que o agente le.
|
|
12
|
+
* Sem corpo interpretavel, o status ao menos diz que houve resposta.
|
|
13
|
+
*/
|
|
14
|
+
export async function recusa(resposta) {
|
|
15
|
+
const { erro } = (await resposta.json().catch(() => ({})));
|
|
16
|
+
throw new Error(erro ?? `a API respondeu ${resposta.status}.`);
|
|
17
|
+
}
|
|
18
|
+
export function cabecalhos(token, comCorpo = false) {
|
|
19
|
+
return comCorpo
|
|
20
|
+
? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
21
|
+
: { Authorization: `Bearer ${token}` };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `fetch` com nova tentativa para falha transitoria.
|
|
25
|
+
*
|
|
26
|
+
* Medido em uso real: um `ECONNRESET` de conexao ociosa derrubou um `arquivar` que
|
|
27
|
+
* funcionou na segunda tentativa manual. Sem retry, falha de rede de um segundo vira erro
|
|
28
|
+
* final — e para um agente isso e a diferenca entre seguir sozinho e parar para pedir
|
|
29
|
+
* ajuda. Com varios agentes concorrendo, transitorio deixa de ser raro.
|
|
30
|
+
*
|
|
31
|
+
* O que NAO e repetido, e o cuidado que importa:
|
|
32
|
+
*
|
|
33
|
+
* - Erro de aplicacao (4xx). Token invalido ou memoria fora dos filtros nao melhora na
|
|
34
|
+
* segunda tentativa; repetir so atrasa a mensagem que o agente precisa ler.
|
|
35
|
+
* - 5xx em requisicao que ESCREVE. `POST /memorias` pode ter gravado antes de a resposta
|
|
36
|
+
* se perder, e repetir criaria duas. Perder a resposta de uma escrita que funcionou e
|
|
37
|
+
* ruim; gravar duas vezes e pior.
|
|
38
|
+
*
|
|
39
|
+
* Entao: repete falha de REDE (o `fetch` nem chegou a receber resposta) sempre, e 5xx
|
|
40
|
+
* apenas quando o metodo e seguro de repetir.
|
|
41
|
+
*/
|
|
42
|
+
const IDEMPOTENTES = new Set(["GET", "HEAD", "PUT", "DELETE"]);
|
|
43
|
+
const espera = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
export async function pede(url, init = {}, tentativas = 3) {
|
|
45
|
+
const metodo = (init.method ?? "GET").toUpperCase();
|
|
46
|
+
const podeRepetirErroDoServidor = IDEMPOTENTES.has(metodo);
|
|
47
|
+
let ultimoErro;
|
|
48
|
+
for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
|
|
49
|
+
try {
|
|
50
|
+
const resposta = await fetch(url, init);
|
|
51
|
+
if (resposta.status >= 500 && podeRepetirErroDoServidor && tentativa < tentativas) {
|
|
52
|
+
await espera(tentativa * 400);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
return resposta;
|
|
56
|
+
}
|
|
57
|
+
catch (erro) {
|
|
58
|
+
// Aqui o `fetch` falhou antes de qualquer resposta: DNS, conexao recusada,
|
|
59
|
+
// ECONNRESET. Nada foi processado do outro lado, entao repetir e seguro mesmo
|
|
60
|
+
// para POST.
|
|
61
|
+
ultimoErro = erro;
|
|
62
|
+
if (tentativa === tentativas)
|
|
63
|
+
break;
|
|
64
|
+
await espera(tentativa * 400);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const detalhe = ultimoErro instanceof Error ? ultimoErro.message : String(ultimoErro);
|
|
68
|
+
throw new Error(`não consegui falar com o serviço depois de ${tentativas} tentativas: ${detalhe}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* O `GET` devolve o payload inteiro (politica, briefing, pastas e memorias); aqui so o
|
|
4
|
+
* artefato pedido interessa.
|
|
5
|
+
*
|
|
6
|
+
* `null` e `""` significam a mesma coisa para quem le — "ainda nao existe" —, e a diferenca
|
|
7
|
+
* entre elas e detalhe de como a linha foi parar no banco. Quem chama recebe `existe`, que
|
|
8
|
+
* e a pergunta real.
|
|
9
|
+
*/
|
|
10
|
+
export async function leArtefato(raiz, tipo) {
|
|
11
|
+
const { config, token } = await credencial(raiz);
|
|
12
|
+
const url = new URL(`${config.api}/api/v1/artefatos`);
|
|
13
|
+
url.searchParams.set("tenant", config.tenant);
|
|
14
|
+
url.searchParams.set("projeto", config.projeto);
|
|
15
|
+
const resposta = await pede(url, { headers: cabecalhos(token) });
|
|
16
|
+
if (!resposta.ok)
|
|
17
|
+
await recusa(resposta);
|
|
18
|
+
const payload = (await resposta.json());
|
|
19
|
+
const conteudo = typeof payload[tipo] === "string" ? payload[tipo] : "";
|
|
20
|
+
return { conteudo, existe: conteudo.length > 0 };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Substituicao total, nao append: quem quer acrescentar um paragrafo le, concatena e
|
|
24
|
+
* reenvia. Conteudo vazio e valido e significa "apagar" — a linha fica no banco, guardando
|
|
25
|
+
* quem mexeu por ultimo.
|
|
26
|
+
*/
|
|
27
|
+
export async function escreveArtefato(raiz, tipo, conteudo) {
|
|
28
|
+
const { config, token } = await credencial(raiz);
|
|
29
|
+
const resposta = await pede(`${config.api}/api/v1/artefatos`, {
|
|
30
|
+
method: "PUT",
|
|
31
|
+
headers: cabecalhos(token, true),
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
tenant: config.tenant,
|
|
34
|
+
projeto: config.projeto,
|
|
35
|
+
tipo,
|
|
36
|
+
conteudo,
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
if (!resposta.ok)
|
|
40
|
+
await recusa(resposta);
|
|
41
|
+
return (await resposta.json());
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
2
|
+
export async function busca(raiz, consulta, limite) {
|
|
3
|
+
const { config, token } = await credencial(raiz);
|
|
4
|
+
const url = new URL(`${config.api}/api/v1/busca`);
|
|
5
|
+
url.searchParams.set("tenant", config.tenant);
|
|
6
|
+
url.searchParams.set("projeto", config.projeto);
|
|
7
|
+
url.searchParams.set("q", consulta);
|
|
8
|
+
if (limite)
|
|
9
|
+
url.searchParams.set("limite", String(limite));
|
|
10
|
+
const resposta = await pede(url, { headers: cabecalhos(token) });
|
|
11
|
+
if (!resposta.ok)
|
|
12
|
+
await recusa(resposta);
|
|
13
|
+
const lido = (await resposta.json());
|
|
14
|
+
return { semantica: lido.semantica, provedor: lido.provedor, achados: lido.resultados };
|
|
15
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { pede } from "./api.js";
|
|
2
|
+
import { leConfigDoRepo, leToken } from "./config.js";
|
|
3
|
+
import { caminhosDoCommit, memoriasTocadas } from "./diff.js";
|
|
4
|
+
import { mede } from "./medir.js";
|
|
5
|
+
/** Separado do IO para poder ser testado sem rede: dado um Brain, o que se mede. */
|
|
6
|
+
export async function medeAsAncoras(raiz, brain) {
|
|
7
|
+
const medicoes = [];
|
|
8
|
+
for (const memoria of brain.memorias) {
|
|
9
|
+
// Memória arquivada não interessa: ela já saiu de circulação, e medir o alvo dela
|
|
10
|
+
// encheria a fila de deriva que ninguém vai resolver.
|
|
11
|
+
if (memoria.status !== "ativa")
|
|
12
|
+
continue;
|
|
13
|
+
for (const ancora of memoria.ancoras) {
|
|
14
|
+
const sha = await mede(raiz, ancora.valor);
|
|
15
|
+
medicoes.push({
|
|
16
|
+
pasta: memoria.pasta,
|
|
17
|
+
memoria: memoria.slug,
|
|
18
|
+
valor: ancora.valor,
|
|
19
|
+
sha,
|
|
20
|
+
...(sha === null ? { detalhe: "alvo não encontrado na árvore de trabalho" } : {}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return medicoes;
|
|
25
|
+
}
|
|
26
|
+
async function buscaOBrain(raiz) {
|
|
27
|
+
const config = await leConfigDoRepo(raiz);
|
|
28
|
+
const token = await leToken(config.api);
|
|
29
|
+
if (!token) {
|
|
30
|
+
throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
|
|
31
|
+
}
|
|
32
|
+
const parametros = `tenant=${encodeURIComponent(config.tenant)}&projeto=${encodeURIComponent(config.projeto)}`;
|
|
33
|
+
const resposta = await pede(`${config.api}/api/v1/artefatos?${parametros}`, {
|
|
34
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
35
|
+
});
|
|
36
|
+
if (resposta.status === 401) {
|
|
37
|
+
throw new Error("token recusado. Rode `dd-harness login --token <token>`.");
|
|
38
|
+
}
|
|
39
|
+
if (!resposta.ok) {
|
|
40
|
+
throw new Error(`a API respondeu ${resposta.status} ao buscar as âncoras.`);
|
|
41
|
+
}
|
|
42
|
+
return { config, token, brain: (await resposta.json()) };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Somente leitura, de proposito: e o que roda na ABERTURA da sessao. Medir e reportar
|
|
46
|
+
* ali dentro faria toda sessao escrever no servico antes de a pessoa digitar qualquer
|
|
47
|
+
* coisa — caro, e o tipo de efeito colateral que ninguem espera de "abrir o editor".
|
|
48
|
+
*/
|
|
49
|
+
export async function status(raiz) {
|
|
50
|
+
const { brain } = await buscaOBrain(raiz);
|
|
51
|
+
const agora = Date.now();
|
|
52
|
+
const ativas = brain.memorias.filter((m) => m.status === "ativa");
|
|
53
|
+
const porPasta = new Map();
|
|
54
|
+
for (const m of ativas)
|
|
55
|
+
porPasta.set(m.pasta, (porPasta.get(m.pasta) ?? 0) + 1);
|
|
56
|
+
return {
|
|
57
|
+
acervo: {
|
|
58
|
+
ativas: ativas.length,
|
|
59
|
+
arquivadas: brain.memorias.length - ativas.length,
|
|
60
|
+
porPasta: [...porPasta]
|
|
61
|
+
.map(([pasta, quantas]) => ({ pasta, quantas }))
|
|
62
|
+
.sort((a, b) => a.pasta.localeCompare(b.pasta)),
|
|
63
|
+
},
|
|
64
|
+
comDeriva: ativas
|
|
65
|
+
.filter((m) => (m.deriva_aberta ?? 0) > 0)
|
|
66
|
+
.map((m) => ({
|
|
67
|
+
pasta: m.pasta,
|
|
68
|
+
memoria: m.slug,
|
|
69
|
+
titulo: m.titulo,
|
|
70
|
+
abertas: m.deriva_aberta ?? 0,
|
|
71
|
+
})),
|
|
72
|
+
vencidas: ativas
|
|
73
|
+
.filter((m) => m.revisar_ate && new Date(m.revisar_ate).getTime() < agora)
|
|
74
|
+
.map((m) => ({ pasta: m.pasta, memoria: m.slug, titulo: m.titulo })),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function check(raiz, commit) {
|
|
78
|
+
const { config, token, brain } = await buscaOBrain(raiz);
|
|
79
|
+
const cabecalhos = { Authorization: `Bearer ${token}` };
|
|
80
|
+
const medicoes = await medeAsAncoras(raiz, brain);
|
|
81
|
+
// O cruzamento com o diff so faz sentido quando ha um commit para olhar.
|
|
82
|
+
const tocadas = commit
|
|
83
|
+
? memoriasTocadas(brain, await caminhosDoCommit(raiz, commit))
|
|
84
|
+
: [];
|
|
85
|
+
if (medicoes.length === 0) {
|
|
86
|
+
return { medidas: 0, ausentes: [], novas: 0, base: 0, jaAbertas: 0, fechadas: 0, tocadas };
|
|
87
|
+
}
|
|
88
|
+
const envio = await pede(`${config.api}/api/v1/deriva`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { ...cabecalhos, "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
tenant: config.tenant,
|
|
93
|
+
projeto: config.projeto,
|
|
94
|
+
commit: commit ?? null,
|
|
95
|
+
medicoes,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
if (envio.status === 403) {
|
|
99
|
+
throw new Error("este token não pode reportar deriva. Crie outro na interface e refaça o login.");
|
|
100
|
+
}
|
|
101
|
+
if (!envio.ok) {
|
|
102
|
+
throw new Error(`a API respondeu ${envio.status} ao registrar as medições.`);
|
|
103
|
+
}
|
|
104
|
+
const julgamento = (await envio.json());
|
|
105
|
+
return {
|
|
106
|
+
medidas: medicoes.length,
|
|
107
|
+
ausentes: medicoes.filter((m) => m.sha === null).map((m) => m.valor).sort(),
|
|
108
|
+
novas: julgamento.novas,
|
|
109
|
+
base: julgamento.base,
|
|
110
|
+
jaAbertas: julgamento.ja_abertas,
|
|
111
|
+
fechadas: julgamento.fechadas ?? 0,
|
|
112
|
+
tocadas,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* O servico publicado e o padrao: um repositorio que so declara `tenant` e `projeto` ja
|
|
7
|
+
* fala com quem existe. Para desenvolver contra a maquina local, ponha `api` explicito no
|
|
8
|
+
* `.dd-harness.json` — apontar para `localhost` por omissao deixaria todo repo novo
|
|
9
|
+
* falando com um servidor que ninguem esta rodando.
|
|
10
|
+
*/
|
|
11
|
+
const PADRAO = { api: "https://dd-harness.vercel.app", pasta: "dd-harness" };
|
|
12
|
+
export const CAMINHO_CONFIG = ".dd-harness.json";
|
|
13
|
+
export const CAMINHO_MANIFESTO = "dd-harness/manifest.json";
|
|
14
|
+
export async function leConfigDoRepo(raiz) {
|
|
15
|
+
let cru;
|
|
16
|
+
try {
|
|
17
|
+
cru = await readFile(join(raiz, CAMINHO_CONFIG), "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`não encontrei ${CAMINHO_CONFIG} em ${raiz}. Crie com: {"tenant":"...","projeto":"..."}`);
|
|
21
|
+
}
|
|
22
|
+
const lido = JSON.parse(cru);
|
|
23
|
+
if (!lido.tenant || !lido.projeto) {
|
|
24
|
+
throw new Error(`${CAMINHO_CONFIG} precisa de "tenant" e "projeto".`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
api: (lido.api ?? PADRAO.api).replace(/\/$/, ""),
|
|
28
|
+
tenant: lido.tenant,
|
|
29
|
+
projeto: lido.projeto,
|
|
30
|
+
pasta: lido.pasta ?? PADRAO.pasta,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const arquivoDeCredenciais = () => join(homedir(), ".dd-harness", "credentials.json");
|
|
34
|
+
/**
|
|
35
|
+
* Credencial por origem da API: a mesma maquina pode falar com uma instalacao local e
|
|
36
|
+
* com a de producao, e misturar token entre as duas daria 401 confuso.
|
|
37
|
+
*/
|
|
38
|
+
export async function leToken(api) {
|
|
39
|
+
try {
|
|
40
|
+
const cru = await readFile(arquivoDeCredenciais(), "utf8");
|
|
41
|
+
const mapa = JSON.parse(cru);
|
|
42
|
+
return mapa[api] ?? null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function guardaToken(api, token) {
|
|
49
|
+
const caminho = arquivoDeCredenciais();
|
|
50
|
+
await mkdir(join(homedir(), ".dd-harness"), { recursive: true });
|
|
51
|
+
let mapa = {};
|
|
52
|
+
try {
|
|
53
|
+
mapa = JSON.parse(await readFile(caminho, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Primeiro token desta maquina.
|
|
57
|
+
}
|
|
58
|
+
mapa[api] = token;
|
|
59
|
+
await writeFile(caminho, `${JSON.stringify(mapa, null, 2)}\n`, "utf8");
|
|
60
|
+
// 0600 depois de escrever. No Windows o chmod e no-op — o arquivo herda a ACL do
|
|
61
|
+
// perfil do usuario, que ja e privada; em POSIX sem isto o token nasce legivel por
|
|
62
|
+
// qualquer processo do sistema.
|
|
63
|
+
await chmod(caminho, 0o600).catch(() => { });
|
|
64
|
+
return caminho;
|
|
65
|
+
}
|
|
66
|
+
export const manifestoVazio = () => ({ etag: null, arquivos: {} });
|
|
67
|
+
export async function leManifesto(raiz) {
|
|
68
|
+
try {
|
|
69
|
+
const cru = await readFile(join(raiz, CAMINHO_MANIFESTO), "utf8");
|
|
70
|
+
const lido = JSON.parse(cru);
|
|
71
|
+
return { etag: lido.etag ?? null, arquivos: lido.arquivos ?? {} };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return manifestoVazio();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function gravaManifesto(raiz, manifesto) {
|
|
78
|
+
await mkdir(join(raiz, "dd-harness"), { recursive: true });
|
|
79
|
+
await writeFile(join(raiz, CAMINHO_MANIFESTO), `${JSON.stringify(manifesto, null, 2)}\n`, "utf8");
|
|
80
|
+
}
|
|
81
|
+
export const hashDe = (conteudo) => createHash("sha256").update(conteudo, "utf8").digest("hex");
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
4
|
+
import { gravaManifesto, hashDe, leManifesto } from "./config.js";
|
|
5
|
+
import { interpreta } from "./gravar.js";
|
|
6
|
+
/**
|
|
7
|
+
* Curadoria pelo agente: editar e arquivar.
|
|
8
|
+
*
|
|
9
|
+
* `gravar` sabia criar e mais nada. Uma memoria errada ficava errada, porque corrigir
|
|
10
|
+
* exigia abrir a interface — e o `CLAUDE.md` trata curadoria como obrigacao ("se
|
|
11
|
+
* encontrar uma memoria obsoleta ou errada, corrija").
|
|
12
|
+
*
|
|
13
|
+
* Editar reaproveita o mesmo markdown de `gravar`, de proposito: o agente edita o arquivo
|
|
14
|
+
* que o `sync` materializou e manda de volta. Um formato so para as duas operacoes, e o
|
|
15
|
+
* que ele ja sabe ler.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Depois que o servico aceita, o arquivo em disco deixa de ser "edicao nao enviada" — e o
|
|
19
|
+
* manifesto tem que saber, senao o `sync` seguinte para com "editado a mao" e a unica saida
|
|
20
|
+
* que resta e descartar o arquivo. O ciclo materializa-corrige-envia travava justamente
|
|
21
|
+
* aqui, com o servico ja atualizado.
|
|
22
|
+
*
|
|
23
|
+
* Guarda o hash do que foi enviado, nao do que o servico devolveria: o `sync` seguinte
|
|
24
|
+
* reescreve o arquivo na forma canonica quando o ETag mudar.
|
|
25
|
+
*/
|
|
26
|
+
async function marcaComoEnviado(raiz, caminho, conteudo) {
|
|
27
|
+
const chave = relative(raiz, caminho).split("\\").join("/");
|
|
28
|
+
const manifesto = await leManifesto(raiz);
|
|
29
|
+
// Arquivo de fora do repositorio (um rascunho em /tmp, por exemplo) nao esta no
|
|
30
|
+
// manifesto e nao deve entrar: o manifesto descreve o que o `sync` materializou.
|
|
31
|
+
if (!manifesto.arquivos[chave])
|
|
32
|
+
return;
|
|
33
|
+
manifesto.arquivos[chave] = hashDe(conteudo);
|
|
34
|
+
await gravaManifesto(raiz, manifesto);
|
|
35
|
+
}
|
|
36
|
+
export async function edita(raiz, caminho) {
|
|
37
|
+
const { config, token } = await credencial(raiz);
|
|
38
|
+
const cru = await readFile(caminho, "utf8");
|
|
39
|
+
const memoria = interpreta(cru);
|
|
40
|
+
const endereco = `${memoria.pasta}/${memoria.slug}`;
|
|
41
|
+
const resposta = await pede(`${config.api}/api/v1/memorias/${endereco}`, {
|
|
42
|
+
method: "PATCH",
|
|
43
|
+
headers: cabecalhos(token, true),
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
tenant: config.tenant,
|
|
46
|
+
projeto: config.projeto,
|
|
47
|
+
titulo: memoria.titulo,
|
|
48
|
+
resumo: memoria.resumo,
|
|
49
|
+
corpo: memoria.corpo,
|
|
50
|
+
dano: memoria.dano,
|
|
51
|
+
invisibilidade: memoria.invisibilidade,
|
|
52
|
+
externalidade: memoria.externalidade,
|
|
53
|
+
ancoras: memoria.ancoras,
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
if (!resposta.ok)
|
|
57
|
+
await recusa(resposta);
|
|
58
|
+
await marcaComoEnviado(raiz, caminho, cru);
|
|
59
|
+
return { endereco, ancoras: memoria.ancoras.length };
|
|
60
|
+
}
|
|
61
|
+
export async function arquiva(raiz, endereco, opcoes) {
|
|
62
|
+
const { config, token } = await credencial(raiz);
|
|
63
|
+
const resposta = await pede(`${config.api}/api/v1/memorias/${endereco}`, {
|
|
64
|
+
method: "DELETE",
|
|
65
|
+
headers: cabecalhos(token, true),
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
tenant: config.tenant,
|
|
68
|
+
projeto: config.projeto,
|
|
69
|
+
motivo: opcoes.motivo,
|
|
70
|
+
...(opcoes.substituidaPor ? { substituida_por: opcoes.substituidaPor } : {}),
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
if (!resposta.ok)
|
|
74
|
+
await recusa(resposta);
|
|
75
|
+
const lido = (await resposta.json());
|
|
76
|
+
return lido;
|
|
77
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const roda = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Quais memorias falam do que este commit mudou.
|
|
6
|
+
*
|
|
7
|
+
* E a peca que devolve a memoria ao code review. Tirar o Brain do repositorio tirou o
|
|
8
|
+
* revisor que o diff dava de graca — no modelo file-based a memoria estava ali, no
|
|
9
|
+
* caminho de quem mexia. Aqui ela volta pelo cruzamento: caminho do diff contra ancora.
|
|
10
|
+
*
|
|
11
|
+
* Repare que isto NAO e deriva. Deriva e "o alvo mudou desde a linha de base"; isto e
|
|
12
|
+
* "voce acabou de mexer no que esta memoria guarda" — util mesmo quando a base ainda
|
|
13
|
+
* nem foi medida, e util mesmo que a memoria continue valendo.
|
|
14
|
+
*/
|
|
15
|
+
/** Caminhos alterados num commit. Vazio quando o git nao responde — nunca quebra. */
|
|
16
|
+
export async function caminhosDoCommit(raiz, commit) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await roda("git", ["diff-tree", "--no-commit-id", "--name-only", "-r", commit], { cwd: raiz });
|
|
19
|
+
return stdout
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.map((l) => l.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Sem git, commit inexistente, repositorio raso: o cruzamento simplesmente nao
|
|
26
|
+
// acontece. Um aviso que nao pode ser dado nao vira erro que atrapalha o commit.
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Cruza caminhos com ancoras. A ancora casa quando e o proprio caminho ou quando e um
|
|
32
|
+
* diretorio que o contem — `supabase/migrations` tem que casar com a migration nova.
|
|
33
|
+
*/
|
|
34
|
+
export function memoriasTocadas(brain, caminhos) {
|
|
35
|
+
const tocadas = [];
|
|
36
|
+
for (const memoria of brain.memorias) {
|
|
37
|
+
if (memoria.status !== "ativa")
|
|
38
|
+
continue;
|
|
39
|
+
for (const ancora of memoria.ancoras) {
|
|
40
|
+
const casa = caminhos.some((c) => c === ancora.valor || c.startsWith(`${ancora.valor}/`));
|
|
41
|
+
if (casa) {
|
|
42
|
+
tocadas.push({
|
|
43
|
+
pasta: memoria.pasta,
|
|
44
|
+
memoria: memoria.slug,
|
|
45
|
+
titulo: memoria.titulo,
|
|
46
|
+
ancora: ancora.valor,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return tocadas;
|
|
52
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
3
|
+
const OBRIGATORIOS = ["name", "titulo", "description", "pasta"];
|
|
4
|
+
/** Frontmatter simples: `chave: valor` por linha. Sem lib de YAML — nao ha aninhamento. */
|
|
5
|
+
function leFrontmatter(texto) {
|
|
6
|
+
const linhas = texto.replace(/\r\n/g, "\n").split("\n");
|
|
7
|
+
if (linhas[0]?.trim() !== "---") {
|
|
8
|
+
throw new Error("o arquivo precisa começar com `---` e um frontmatter.");
|
|
9
|
+
}
|
|
10
|
+
const fim = linhas.indexOf("---", 1);
|
|
11
|
+
if (fim === -1)
|
|
12
|
+
throw new Error("frontmatter sem `---` de fechamento.");
|
|
13
|
+
const campos = new Map();
|
|
14
|
+
for (const linha of linhas.slice(1, fim)) {
|
|
15
|
+
const corte = linha.indexOf(":");
|
|
16
|
+
if (corte === -1)
|
|
17
|
+
continue;
|
|
18
|
+
campos.set(linha.slice(0, corte).trim(), linha.slice(corte + 1).trim());
|
|
19
|
+
}
|
|
20
|
+
return { campos, resto: linhas.slice(fim + 1).join("\n") };
|
|
21
|
+
}
|
|
22
|
+
/** `**Dano:** texto` — devolve o texto, sem o rotulo. */
|
|
23
|
+
function filtro(corpo, rotulo) {
|
|
24
|
+
const achado = corpo.match(new RegExp(`\\*\\*${rotulo}:\\*\\*\\s*([\\s\\S]*?)(?=\\n\\s*\\n\\*\\*|\\n## |$)`, "i"));
|
|
25
|
+
const valor = achado?.[1]?.trim() ?? "";
|
|
26
|
+
if (!valor)
|
|
27
|
+
throw new Error(`falta o filtro **${rotulo}:** no arquivo.`);
|
|
28
|
+
return valor;
|
|
29
|
+
}
|
|
30
|
+
/** Itens de lista da secao de ancoras, com ou sem crase em volta. */
|
|
31
|
+
function ancorasDe(texto) {
|
|
32
|
+
const secao = texto.split(/\n##\s+[ÂA]ncoras\s*\n/i)[1];
|
|
33
|
+
if (!secao)
|
|
34
|
+
return [];
|
|
35
|
+
return secao
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((l) => l.match(/^\s*[-*]\s+(.+?)\s*$/)?.[1])
|
|
38
|
+
.filter((v) => Boolean(v))
|
|
39
|
+
.map((v) => v.replace(/^`|`$/g, "").trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
export function interpreta(texto) {
|
|
43
|
+
const { campos, resto } = leFrontmatter(texto);
|
|
44
|
+
for (const chave of OBRIGATORIOS) {
|
|
45
|
+
if (!campos.get(chave))
|
|
46
|
+
throw new Error(`frontmatter sem \`${chave}\`.`);
|
|
47
|
+
}
|
|
48
|
+
// O corpo vai ate a secao dos filtros; dali para baixo e metadado, nao conteudo.
|
|
49
|
+
const corte = resto.search(/\n##\s+Os tr[êe]s filtros\s*\n/i);
|
|
50
|
+
if (corte === -1)
|
|
51
|
+
throw new Error("falta a seção `## Os três filtros`.");
|
|
52
|
+
const corpo = resto
|
|
53
|
+
.slice(0, corte)
|
|
54
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
55
|
+
.trim();
|
|
56
|
+
if (!corpo)
|
|
57
|
+
throw new Error("o corpo da memória está vazio.");
|
|
58
|
+
const cauda = resto.slice(corte);
|
|
59
|
+
const tambem = campos.get("projetos") ?? "";
|
|
60
|
+
return {
|
|
61
|
+
slug: campos.get("name"),
|
|
62
|
+
titulo: campos.get("titulo"),
|
|
63
|
+
resumo: campos.get("description"),
|
|
64
|
+
pasta: campos.get("pasta"),
|
|
65
|
+
corpo,
|
|
66
|
+
dano: filtro(cauda, "Dano"),
|
|
67
|
+
invisibilidade: filtro(cauda, "Invisibilidade"),
|
|
68
|
+
externalidade: filtro(cauda, "Externalidade"),
|
|
69
|
+
ancoras: ancorasDe(cauda),
|
|
70
|
+
tambemEm: tambem
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((s) => s.trim())
|
|
73
|
+
.filter(Boolean),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export async function grava(raiz, caminho) {
|
|
77
|
+
const { config, token } = await credencial(raiz);
|
|
78
|
+
const memoria = interpreta(await readFile(caminho, "utf8"));
|
|
79
|
+
const resposta = await pede(`${config.api}/api/v1/memorias`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: cabecalhos(token, true),
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
tenant: config.tenant,
|
|
84
|
+
projeto: config.projeto,
|
|
85
|
+
pasta: memoria.pasta,
|
|
86
|
+
slug: memoria.slug,
|
|
87
|
+
titulo: memoria.titulo,
|
|
88
|
+
resumo: memoria.resumo,
|
|
89
|
+
corpo: memoria.corpo,
|
|
90
|
+
dano: memoria.dano,
|
|
91
|
+
invisibilidade: memoria.invisibilidade,
|
|
92
|
+
externalidade: memoria.externalidade,
|
|
93
|
+
ancoras: memoria.ancoras,
|
|
94
|
+
tambem_em: memoria.tambemEm,
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
if (!resposta.ok)
|
|
98
|
+
await recusa(resposta);
|
|
99
|
+
const { endereco } = (await resposta.json());
|
|
100
|
+
return {
|
|
101
|
+
endereco,
|
|
102
|
+
ancoras: memoria.ancoras.length,
|
|
103
|
+
projetos: 1 + memoria.tambemEm.length,
|
|
104
|
+
};
|
|
105
|
+
}
|