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,30 @@
|
|
|
1
|
+
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* `dd-harness pasta <slug> --definicao "..."` — cria a pasta sem sair da sessao.
|
|
4
|
+
*
|
|
5
|
+
* `gravar` recusa quando a pasta nao existe, e essa recusa e boa: e ela que impede um
|
|
6
|
+
* typo no `pasta:` de virar pasta nova em silencio. O que faltava era a saida — ate aqui
|
|
7
|
+
* o agente parava e pedia que alguem abrisse o navegador.
|
|
8
|
+
*/
|
|
9
|
+
export async function criaPasta(raiz, slug, definicao) {
|
|
10
|
+
const { config, token } = await credencial(raiz);
|
|
11
|
+
const resposta = await pede(`${config.api}/api/v1/pastas`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: cabecalhos(token, true),
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
tenant: config.tenant,
|
|
16
|
+
projeto: config.projeto,
|
|
17
|
+
slug,
|
|
18
|
+
definicao,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
// Pasta que ja existe nao e falha para quem chamou: o estado desejado ja vale. Vale
|
|
22
|
+
// dizer que ja existia — quem pediu pode estar corrigindo um typo e precisa saber que
|
|
23
|
+
// nao criou nada.
|
|
24
|
+
if (resposta.status === 409)
|
|
25
|
+
return { pasta: slug, jaExistia: true };
|
|
26
|
+
if (!resposta.ok)
|
|
27
|
+
await recusa(resposta);
|
|
28
|
+
const { pasta } = (await resposta.json());
|
|
29
|
+
return { pasta, jaExistia: false };
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cabecalhos, pede, recusa } from "./api.js";
|
|
2
|
+
import { leConfigDoRepo, leToken } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* `dd-harness projeto <slug> --nome "..."` — cria o projeto sem sair da sessao.
|
|
5
|
+
*
|
|
6
|
+
* Era o ultimo passo do espaco que exigia navegador. O tenant continua manual de
|
|
7
|
+
* proposito (fronteira de isolamento e ato de dono), mas daqui para baixo o agente que
|
|
8
|
+
* abre um repositorio novo consegue preparar tudo: projeto, pasta e memoria.
|
|
9
|
+
*
|
|
10
|
+
* Nao escreve o `.dd-harness.json`: quem faz isso e o `init`, e ele precisa de um projeto
|
|
11
|
+
* que ja exista. A ordem e `projeto` e depois `init` — e por isso este comando NAO pode
|
|
12
|
+
* exigir o config do repo, que nesse momento ainda nao existe. Daí `--tenant` e `--api`:
|
|
13
|
+
* sem config, eles vem da linha de comando; com config, ele preenche o que faltar.
|
|
14
|
+
*/
|
|
15
|
+
export async function criaProjeto(raiz, slug, nome, explicito = {}) {
|
|
16
|
+
const doRepo = await leConfigDoRepo(raiz).catch(() => null);
|
|
17
|
+
const tenant = explicito.tenant ?? doRepo?.tenant;
|
|
18
|
+
const api = (explicito.api ?? doRepo?.api ?? "https://dd-harness.vercel.app").replace(/\/$/, "");
|
|
19
|
+
if (!tenant) {
|
|
20
|
+
throw new Error("não sei em que espaço criar: passe `--tenant <slug>` (ou rode dentro de um repositório já com .dd-harness.json).");
|
|
21
|
+
}
|
|
22
|
+
const token = await leToken(api);
|
|
23
|
+
if (!token) {
|
|
24
|
+
throw new Error(`sem credencial para ${api}. Rode \`dd-harness login --token <token>\`.`);
|
|
25
|
+
}
|
|
26
|
+
const resposta = await pede(`${api}/api/v1/projetos`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: cabecalhos(token, true),
|
|
29
|
+
body: JSON.stringify({ tenant, slug, nome }),
|
|
30
|
+
});
|
|
31
|
+
// Como em `pasta`: projeto que ja existe nao e falha para quem chamou, o estado desejado
|
|
32
|
+
// ja vale. Vale dizer que existia — quem pediu pode estar corrigindo um typo.
|
|
33
|
+
if (resposta.status === 409)
|
|
34
|
+
return { projeto: slug, jaExistia: true };
|
|
35
|
+
if (!resposta.ok)
|
|
36
|
+
await recusa(resposta);
|
|
37
|
+
const { projeto } = (await resposta.json());
|
|
38
|
+
return { projeto, jaExistia: false };
|
|
39
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { pede } from "./api.js";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { gravaManifesto, hashDe, leConfigDoRepo, leManifesto, leToken, } from "./config.js";
|
|
5
|
+
import { LINHA_DE_IMPORT, materializa } from "./materializa.js";
|
|
6
|
+
async function leSeExistir(caminho) {
|
|
7
|
+
try {
|
|
8
|
+
return await readFile(caminho, "utf8");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Estado do que este comando escreveu, contra o hash guardado.
|
|
16
|
+
*
|
|
17
|
+
* Duas situacoes diferentes, e confundi-las custa caro: hash diferente e **edicao a
|
|
18
|
+
* mao**, e o comando tem que parar sem escrever; arquivo **ausente** nao e edicao — e
|
|
19
|
+
* trabalho a refazer, e o comando tem que reescrever.
|
|
20
|
+
*/
|
|
21
|
+
export async function confereDisco(raiz, arquivos) {
|
|
22
|
+
const editados = [];
|
|
23
|
+
const faltando = [];
|
|
24
|
+
for (const [relativo, hashGuardado] of Object.entries(arquivos)) {
|
|
25
|
+
const atual = await leSeExistir(join(raiz, relativo));
|
|
26
|
+
if (atual === null)
|
|
27
|
+
faltando.push(relativo);
|
|
28
|
+
else if (hashDe(atual) !== hashGuardado)
|
|
29
|
+
editados.push(relativo);
|
|
30
|
+
}
|
|
31
|
+
return { editados: editados.sort(), faltando: faltando.sort() };
|
|
32
|
+
}
|
|
33
|
+
async function buscaBrain(config, token, etag) {
|
|
34
|
+
const url = `${config.api}/api/v1/artefatos?tenant=${encodeURIComponent(config.tenant)}&projeto=${encodeURIComponent(config.projeto)}`;
|
|
35
|
+
const resposta = await pede(url, {
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${token}`,
|
|
38
|
+
...(etag ? { "If-None-Match": etag } : {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (resposta.status === 304)
|
|
42
|
+
return { naoMudou: true };
|
|
43
|
+
if (resposta.status === 401) {
|
|
44
|
+
throw new Error("token recusado. Rode `dd-harness login --token <token>`.");
|
|
45
|
+
}
|
|
46
|
+
if (resposta.status === 404) {
|
|
47
|
+
throw new Error(`projeto ${config.tenant}/${config.projeto} não encontrado — ou não é seu.`);
|
|
48
|
+
}
|
|
49
|
+
if (!resposta.ok) {
|
|
50
|
+
throw new Error(`a API respondeu ${resposta.status}.`);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
naoMudou: false,
|
|
54
|
+
brain: (await resposta.json()),
|
|
55
|
+
etag: resposta.headers.get("etag"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* O `CLAUDE.md` da raiz existe e importa a politica?
|
|
60
|
+
*
|
|
61
|
+
* Sem politica no servico E sem a linha, nao ha o que apontar nem o que avisar — avisar
|
|
62
|
+
* ai seria ruido que ensina a ignorar aviso. Mas com a linha escrita e a politica vazia o
|
|
63
|
+
* import esta pendurado de verdade, e esse e o silencio que a memoria
|
|
64
|
+
* `import-do-claude-md-falha-calado` manda quebrar.
|
|
65
|
+
*/
|
|
66
|
+
export async function confereOPonteiro(raiz, temPolitica) {
|
|
67
|
+
const claudeMd = await leSeExistir(join(raiz, "CLAUDE.md"));
|
|
68
|
+
const temALinha = claudeMd?.includes(LINHA_DE_IMPORT) ?? false;
|
|
69
|
+
if (!temPolitica)
|
|
70
|
+
return temALinha ? "aponta-para-o-vazio" : "sem-politica";
|
|
71
|
+
if (claudeMd === null)
|
|
72
|
+
return "sem-claude-md";
|
|
73
|
+
return temALinha ? "ok" : "sem-a-linha";
|
|
74
|
+
}
|
|
75
|
+
export async function sync(raiz) {
|
|
76
|
+
const config = await leConfigDoRepo(raiz);
|
|
77
|
+
const token = await leToken(config.api);
|
|
78
|
+
if (!token) {
|
|
79
|
+
throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
|
|
80
|
+
}
|
|
81
|
+
const manifesto = await leManifesto(raiz);
|
|
82
|
+
// A guarda vem ANTES da rede, e nao depois do 304, porque a divergencia pode estar
|
|
83
|
+
// deste lado: servico igual e disco editado a mao e o caso que mais interessa avisar,
|
|
84
|
+
// e um 304 que respondesse "nada mudou" o esconderia.
|
|
85
|
+
const { editados, faltando } = await confereDisco(raiz, manifesto.arquivos);
|
|
86
|
+
const politicaNoManifesto = Object.keys(manifesto.arquivos).some((a) => a.endsWith(`${config.pasta}/politica.md`));
|
|
87
|
+
if (editados.length) {
|
|
88
|
+
return {
|
|
89
|
+
tipo: "editado-a-mao",
|
|
90
|
+
arquivos: editados,
|
|
91
|
+
ponteiro: await confereOPonteiro(raiz, politicaNoManifesto),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// `If-None-Match` so quando o disco esta completo. Faltando arquivo, um 304 diria
|
|
95
|
+
// "nada a fazer" e o comando nunca conseguiria reescrever o que foi apagado — a
|
|
96
|
+
// sincronizacao ficaria incapaz de consertar o disco, que e metade do trabalho dela.
|
|
97
|
+
const resposta = await buscaBrain(config, token, faltando.length ? null : manifesto.etag);
|
|
98
|
+
if (resposta.naoMudou) {
|
|
99
|
+
// Confere mesmo sem mudanca no servico: quem apaga a linha de import e quem mexe no
|
|
100
|
+
// repositorio, e um 304 nao sabe nada sobre isso.
|
|
101
|
+
return {
|
|
102
|
+
tipo: "sem-mudanca",
|
|
103
|
+
ponteiro: await confereOPonteiro(raiz, politicaNoManifesto),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const desejado = materializa(resposta.brain, config.pasta);
|
|
107
|
+
// Arquivo que o servico quer escrever e que existe em disco sem estar no manifesto e
|
|
108
|
+
// de outra origem — escrito a mao antes do primeiro sync. Tambem nao e nosso.
|
|
109
|
+
const alheios = [];
|
|
110
|
+
for (const relativo of desejado.keys()) {
|
|
111
|
+
if (manifesto.arquivos[relativo])
|
|
112
|
+
continue;
|
|
113
|
+
if ((await leSeExistir(join(raiz, relativo))) !== null)
|
|
114
|
+
alheios.push(relativo);
|
|
115
|
+
}
|
|
116
|
+
if (alheios.length) {
|
|
117
|
+
return {
|
|
118
|
+
tipo: "editado-a-mao",
|
|
119
|
+
arquivos: alheios.sort(),
|
|
120
|
+
ponteiro: await confereOPonteiro(raiz, desejado.has(`${config.pasta}/politica.md`)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const escritos = [];
|
|
124
|
+
for (const [relativo, conteudo] of desejado) {
|
|
125
|
+
const caminho = join(raiz, relativo);
|
|
126
|
+
const atual = await leSeExistir(caminho);
|
|
127
|
+
if (atual === conteudo)
|
|
128
|
+
continue;
|
|
129
|
+
await mkdir(dirname(caminho), { recursive: true });
|
|
130
|
+
await writeFile(caminho, conteudo, "utf8");
|
|
131
|
+
escritos.push(relativo);
|
|
132
|
+
}
|
|
133
|
+
// Memoria que saiu do servico tem que sair do disco, senao o repo guarda para sempre
|
|
134
|
+
// uma memoria que ninguem mais vai atualizar. So remove o que era nosso e nao mudou.
|
|
135
|
+
const removidos = [];
|
|
136
|
+
for (const relativo of Object.keys(manifesto.arquivos)) {
|
|
137
|
+
if (desejado.has(relativo))
|
|
138
|
+
continue;
|
|
139
|
+
await rm(join(raiz, relativo), { force: true });
|
|
140
|
+
removidos.push(relativo);
|
|
141
|
+
}
|
|
142
|
+
const novo = {
|
|
143
|
+
etag: resposta.etag,
|
|
144
|
+
arquivos: Object.fromEntries([...desejado].map(([relativo, conteudo]) => [relativo, hashDe(conteudo)])),
|
|
145
|
+
};
|
|
146
|
+
await gravaManifesto(raiz, novo);
|
|
147
|
+
return {
|
|
148
|
+
tipo: "sincronizado",
|
|
149
|
+
escritos: escritos.sort(),
|
|
150
|
+
removidos: removidos.sort(),
|
|
151
|
+
ponteiro: await confereOPonteiro(raiz, desejado.has(`${config.pasta}/politica.md`)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import * as z from "zod/v4";
|
|
4
|
+
// Caminho relativo ao fonte do CLI, nao `dd-harness/*`: aquele campo aponta para `dist`,
|
|
5
|
+
// que e gerado por build e nao vai no git — num checkout limpo (a Vercel) a resolucao
|
|
6
|
+
// falharia, e foi assim que o build de producao caiu uma vez. Aqui o compilador segue o
|
|
7
|
+
// fonte, e o `dist` deste pacote sai com o codigo do CLI embutido.
|
|
8
|
+
import { arquiva, edita } from "../../cli/src/curar.js";
|
|
9
|
+
import { busca } from "../../cli/src/buscar.js";
|
|
10
|
+
import { criaPasta } from "../../cli/src/pasta.js";
|
|
11
|
+
import { criaProjeto } from "../../cli/src/projeto.js";
|
|
12
|
+
import { escreveArtefato, leArtefato } from "../../cli/src/artefato.js";
|
|
13
|
+
import { grava } from "../../cli/src/gravar.js";
|
|
14
|
+
/**
|
|
15
|
+
* O mesmo servico, outra porta.
|
|
16
|
+
*
|
|
17
|
+
* O CLI exige que o agente saiba que ele existe: alguem tem que ter escrito na politica
|
|
18
|
+
* "rode `dd-harness buscar`". Ferramenta MCP chega ao contexto por estar disponivel — o
|
|
19
|
+
* agente le a descricao sem ninguem instruir, e decide se quer.
|
|
20
|
+
*
|
|
21
|
+
* Isso muda o que a descricao precisa carregar. Os tres filtros sao CHECK no banco e prosa
|
|
22
|
+
* na skill: quem nunca leu a skill descobre a regra por 422. Aqui eles vao na descricao da
|
|
23
|
+
* ferramenta, que e onde o agente olha antes de tentar.
|
|
24
|
+
*
|
|
25
|
+
* `init`, `sync`, `login` e `check` ficam de fora de proposito: sao bootstrap e fluxo de
|
|
26
|
+
* quem esta no terminal, e materializar arquivo nao e trabalho de ferramenta de sessao.
|
|
27
|
+
*/
|
|
28
|
+
/** Tudo roda contra o repositorio de onde o host lancou o servidor. */
|
|
29
|
+
const raiz = process.cwd();
|
|
30
|
+
const texto = (t) => ({ content: [{ type: "text", text: t }] });
|
|
31
|
+
const falha = (erro) => ({
|
|
32
|
+
content: [
|
|
33
|
+
{ type: "text", text: erro instanceof Error ? erro.message : String(erro) },
|
|
34
|
+
],
|
|
35
|
+
isError: true,
|
|
36
|
+
});
|
|
37
|
+
const FILTROS = `Os três filtros são CONJUNTIVOS: a memória só nasce se os três valerem, e o banco recusa quem não passa.
|
|
38
|
+
- dano: se ninguém souber disto, alguém desfaz e QUEBRA algo? "é interessante de saber" não é dano.
|
|
39
|
+
- invisibilidade: o próprio repositório já conta (um config, uma dependência, um teste, um nome bem escolhido)? Se conta, NÃO grave. "eu explico melhor que o código" não é invisibilidade.
|
|
40
|
+
- externalidade: a razão vem de FORA do código — sistema legado, compliance, limite de fornecedor, número mágico, bug de terceiro? "foi uma decisão difícil" não é externalidade.
|
|
41
|
+
Decisão difícil e reversível não vira memória — vira commit. Cada campo exige no mínimo 10 caracteres de justificativa real.`;
|
|
42
|
+
function criaServidor() {
|
|
43
|
+
const server = new McpServer({ name: "dd-harness", version: "0.1.0" });
|
|
44
|
+
server.registerTool("buscar_memoria", {
|
|
45
|
+
description: `Procura no Brain do projeto por relevância e devolve endereço, título e resumo — não o corpo. Use quando estiver entendendo um problema e ainda não souber que arquivo abrir ("o que já aprendemos sobre isto?").
|
|
46
|
+
|
|
47
|
+
Para ler o corpo de um achado, abra o arquivo que o \`dd-harness sync\` materializou em \`dd-harness/brain/<endereço>.md\`.
|
|
48
|
+
|
|
49
|
+
A busca combina semântica e lexical e tem piso de similaridade: quando nada é pertinente ela devolve lista vazia em vez de inventar o vizinho mais próximo. Lista vazia é resposta, não falha.
|
|
50
|
+
|
|
51
|
+
Os primeiros achados são os que valem: o piso barra tema alheio, mas num Brain temático a relevância decai de forma contínua, sem corte óbvio. Leia de cima para baixo e pare quando deixar de fazer sentido.`,
|
|
52
|
+
inputSchema: z.object({
|
|
53
|
+
consulta: z
|
|
54
|
+
.string()
|
|
55
|
+
.min(1)
|
|
56
|
+
.describe("A pergunta em texto livre, como você a formularia para um colega."),
|
|
57
|
+
limite: z
|
|
58
|
+
.number()
|
|
59
|
+
.int()
|
|
60
|
+
.positive()
|
|
61
|
+
.max(50)
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Quantos achados no máximo (padrão 4). Suba só quando os primeiros não bastarem: num acervo temático o resultado 10 ainda fala do projeto, mas já não responde à pergunta."),
|
|
64
|
+
}),
|
|
65
|
+
}, async ({ consulta, limite }) => {
|
|
66
|
+
try {
|
|
67
|
+
// Menos que o padrao da rota (10), medido: num acervo tematico a similaridade decai
|
|
68
|
+
// continuamente, sem vale entre pertinente e "do mesmo projeto" — com 15 memorias o
|
|
69
|
+
// limite 10 devolvia dois tercos do Brain, e devolver quase tudo desfaz a
|
|
70
|
+
// recuperacao por relevancia. Quem chama a rota direto continua com 10.
|
|
71
|
+
const r = await busca(raiz, consulta, limite ?? 4);
|
|
72
|
+
if (r.achados.length === 0) {
|
|
73
|
+
return texto(`Nada pertinente no Brain para "${consulta}".` +
|
|
74
|
+
(r.semantica ? "" : " (Busca lexical apenas: sem provedor de embedding configurado.)"));
|
|
75
|
+
}
|
|
76
|
+
const linhas = r.achados.map((a) => `- ${a.endereco} — ${a.titulo}: ${a.resumo}`);
|
|
77
|
+
return texto(`${r.achados.length} achado(s)${r.semantica ? "" : " (busca lexical apenas)"}:\n${linhas.join("\n")}`);
|
|
78
|
+
}
|
|
79
|
+
catch (erro) {
|
|
80
|
+
return falha(erro);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
server.registerTool("gravar_memoria", {
|
|
84
|
+
description: `Registra uma memória nova no Brain do projeto. O default é NÃO gravar: proponha ao humano antes, e grave o que ele aprovar.
|
|
85
|
+
|
|
86
|
+
${FILTROS}
|
|
87
|
+
|
|
88
|
+
A pasta precisa existir antes — use \`criar_pasta\`. Essa recusa é deliberada: ela impede um typo virar pasta nova em silêncio.
|
|
89
|
+
|
|
90
|
+
Âncoras (opcional) amarram a memória ao código, e são o que faz a memória ser recuperada quando alguém mexe naquele ponto. Caminho relativo à raiz, sem \`..\` e sem separador do Windows. Duas formas:
|
|
91
|
+
|
|
92
|
+
- \`src/api/encurtar.js\` — o arquivo inteiro. Use quando a memória fala do arquivo como um todo.
|
|
93
|
+
- \`src/api/encurtar.js#readFileSync\` — só as linhas que contêm \`readFileSync\`. PREFIRA esta: quando várias memórias dividem um arquivo, a âncora de arquivo acusa todas a cada mudança em qualquer parte dele, e o aviso perde valor. Escolha um texto estável e específico do que a memória descreve (um nome de função, uma constante, uma chave de config) — não um trecho que a próxima refatoração reescreve à toa.`,
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
arquivo: z
|
|
96
|
+
.string()
|
|
97
|
+
.min(1)
|
|
98
|
+
.describe("Caminho de um .md no formato que o sync materializa: frontmatter com name/titulo/description/pasta, corpo, e a seção `## Os três filtros` com **Dano:**, **Invisibilidade:** e **Externalidade:**. Âncoras vão numa seção `## Âncoras` como itens de lista."),
|
|
99
|
+
}),
|
|
100
|
+
}, async ({ arquivo }) => {
|
|
101
|
+
try {
|
|
102
|
+
const r = await grava(raiz, arquivo);
|
|
103
|
+
return texto(`Gravado ${r.endereco} — ${r.ancoras} âncora(s), valendo para ${r.projetos} projeto(s).\n` +
|
|
104
|
+
"Rode `dd-harness sync` para materializar, e `pnpm worker --uma-vez` para entrar na busca semântica.");
|
|
105
|
+
}
|
|
106
|
+
catch (erro) {
|
|
107
|
+
return falha(erro);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
server.registerTool("editar_memoria", {
|
|
111
|
+
description: `Corrige uma memória que já existe, pelo mesmo formato markdown de \`gravar_memoria\`. O endereço sai do frontmatter (pasta + name), então edite o arquivo que o \`sync\` materializou.
|
|
112
|
+
|
|
113
|
+
Os três filtros continuam valendo na edição — o banco recusa igual. ${FILTROS}`,
|
|
114
|
+
inputSchema: z.object({
|
|
115
|
+
arquivo: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(1)
|
|
118
|
+
.describe("Caminho do .md da memória, normalmente `dd-harness/brain/<pasta>/<slug>.md`."),
|
|
119
|
+
}),
|
|
120
|
+
}, async ({ arquivo }) => {
|
|
121
|
+
try {
|
|
122
|
+
const r = await edita(raiz, arquivo);
|
|
123
|
+
return texto(`Editado ${r.endereco} — ${r.ancoras} âncora(s).`);
|
|
124
|
+
}
|
|
125
|
+
catch (erro) {
|
|
126
|
+
return falha(erro);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.registerTool("arquivar_memoria", {
|
|
130
|
+
description: `Arquiva uma memória. Nunca apaga: ela sai do Brain ativo e migra para a seção \`## Histórico\` do índice, com o motivo registrado.
|
|
131
|
+
|
|
132
|
+
Use \`substituida_por\` quando outra memória toma o lugar desta — a troca acontece numa transação só, e o histórico aponta para a sucessora.`,
|
|
133
|
+
inputSchema: z.object({
|
|
134
|
+
endereco: z
|
|
135
|
+
.string()
|
|
136
|
+
.min(1)
|
|
137
|
+
.describe("`<pasta>/<slug>`, como aparece no índice do Brain."),
|
|
138
|
+
motivo: z
|
|
139
|
+
.enum(["obsoleta", "incorreta", "fora_dos_filtros"])
|
|
140
|
+
.describe("obsoleta: era verdade e deixou de ser. incorreta: nunca foi verdade. fora_dos_filtros: não passava nos três filtros e não deveria ter sido gravada."),
|
|
141
|
+
substituida_por: z
|
|
142
|
+
.string()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe("`<pasta>/<slug>` da memória que assume o lugar desta, se houver."),
|
|
145
|
+
}),
|
|
146
|
+
}, async ({ endereco, motivo, substituida_por }) => {
|
|
147
|
+
try {
|
|
148
|
+
const r = await arquiva(raiz, endereco, { motivo, substituidaPor: substituida_por });
|
|
149
|
+
return texto(`Arquivado ${r.endereco} — motivo: ${r.motivo}.` +
|
|
150
|
+
(substituida_por ? ` Substituída por ${substituida_por}.` : ""));
|
|
151
|
+
}
|
|
152
|
+
catch (erro) {
|
|
153
|
+
return falha(erro);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
server.registerTool("criar_projeto", {
|
|
157
|
+
description: `Cria um projeto no serviço — o espaço onde as pastas e memórias deste repositório vão morar.
|
|
158
|
+
|
|
159
|
+
Use quando estiver começando num repositório que ainda não tem \`.dd-harness.json\`, ANTES de \`dd-harness init\`: o init escreve a configuração apontando para um projeto, e apontar para um que não existe deixa todo comando seguinte em 404.
|
|
160
|
+
|
|
161
|
+
O \`slug\` é a chave que o repositório guarda, e é único por espaço — renomear depois quebraria o ponteiro, então escolha pensando nisso. O \`nome\` é livre e editável, é o que aparece na interface.
|
|
162
|
+
|
|
163
|
+
O ESPAÇO (tenant) não se cria por aqui, de propósito: ele é a fronteira de isolamento entre pessoas, e isso é decisão do dono. Se não houver espaço nenhum, pare e peça que ele crie pela interface.`,
|
|
164
|
+
inputSchema: z.object({
|
|
165
|
+
slug: z
|
|
166
|
+
.string()
|
|
167
|
+
.min(2)
|
|
168
|
+
.describe("Identificador curto em minúsculas, com hifens: `pingpong3d`."),
|
|
169
|
+
nome: z.string().min(1).describe("Como aparece na interface: `Ping-Pong 3D`."),
|
|
170
|
+
tenant: z
|
|
171
|
+
.string()
|
|
172
|
+
.optional()
|
|
173
|
+
.describe("Slug do espaço. Obrigatório quando ainda não há `.dd-harness.json` no repositório — que é o caso normal de quem está criando o projeto."),
|
|
174
|
+
}),
|
|
175
|
+
}, async ({ slug, nome, tenant }) => {
|
|
176
|
+
try {
|
|
177
|
+
const r = await criaProjeto(raiz, slug, nome, { tenant });
|
|
178
|
+
return texto(r.jaExistia
|
|
179
|
+
? `O projeto ${r.projeto} já existia — nada foi criado.`
|
|
180
|
+
: `Projeto ${r.projeto} criado. Agora rode \`dd-harness init --tenant <espaço> --projeto ${r.projeto}\`.`);
|
|
181
|
+
}
|
|
182
|
+
catch (erro) {
|
|
183
|
+
return falha(erro);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
server.registerTool("criar_pasta", {
|
|
187
|
+
description: `Cria uma pasta temática no Brain. \`gravar_memoria\` recusa quando a pasta não existe, e esta é a saída.
|
|
188
|
+
|
|
189
|
+
A pasta é deste projeto, nomeada pelo domínio dele. Antes de criar uma nova, prefira ENCAIXAR numa que já existe (leia as definições das atuais): pasta de um arquivo só fragmenta o índice e sai do radar. Pasta nova exige que a memória não caiba em nenhuma existente E que você consiga nomear outra memória futura plausível que cairia nela.
|
|
190
|
+
|
|
191
|
+
Criar uma que já existe não é erro: devolve que já existia, sem alterar a definição.`,
|
|
192
|
+
inputSchema: z.object({
|
|
193
|
+
slug: z
|
|
194
|
+
.string()
|
|
195
|
+
.min(1)
|
|
196
|
+
.describe("Identificador curto em minúsculas, como `banco` ou `integracoes`."),
|
|
197
|
+
definicao: z
|
|
198
|
+
.string()
|
|
199
|
+
.min(10)
|
|
200
|
+
.describe("O que entra e o que NÃO entra nesta pasta. É o que se consulta ao decidir onde uma memória vai, então diga também o que fica de fora."),
|
|
201
|
+
}),
|
|
202
|
+
}, async ({ slug, definicao }) => {
|
|
203
|
+
try {
|
|
204
|
+
const r = await criaPasta(raiz, slug, definicao);
|
|
205
|
+
return texto(r.jaExistia
|
|
206
|
+
? `A pasta ${r.pasta} já existia — nada foi criado nem alterado.`
|
|
207
|
+
: `Pasta ${r.pasta} criada.`);
|
|
208
|
+
}
|
|
209
|
+
catch (erro) {
|
|
210
|
+
return falha(erro);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
server.registerTool("ler_artefato", {
|
|
214
|
+
description: `Lê a política ou o briefing do projeto direto do serviço.
|
|
215
|
+
|
|
216
|
+
- \`politica\`: as regras que valem neste projeto — proibições, o protocolo antes de implementar, o que exige autorização. É o que o \`CLAUDE.md\` do repositório importa.
|
|
217
|
+
- \`briefing\`: o retrato estável do projeto — stack, objetivo, restrições.
|
|
218
|
+
|
|
219
|
+
Devolve vazio quando o artefato ainda não existe. Vazio não é erro: projeto novo nasce assim, e a saída é escrever o primeiro conteúdo, não investigar falha.`,
|
|
220
|
+
inputSchema: z.object({
|
|
221
|
+
tipo: z
|
|
222
|
+
.enum(["politica", "briefing"])
|
|
223
|
+
.describe("Qual dos dois artefatos ler."),
|
|
224
|
+
}),
|
|
225
|
+
}, async ({ tipo }) => {
|
|
226
|
+
try {
|
|
227
|
+
const r = await leArtefato(raiz, tipo);
|
|
228
|
+
if (!r.existe) {
|
|
229
|
+
return texto(`O ${tipo} deste projeto está vazio — ainda não foi escrito. Use \`escrever_artefato\` para criar o primeiro conteúdo.`);
|
|
230
|
+
}
|
|
231
|
+
return texto(r.conteudo);
|
|
232
|
+
}
|
|
233
|
+
catch (erro) {
|
|
234
|
+
return falha(erro);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
server.registerTool("escrever_artefato", {
|
|
238
|
+
description: `Substitui a política ou o briefing do projeto. Proponha ao humano antes: a política é a regra que governa as próprias sessões, e trocá-la sem combinar muda o que vale para todo mundo que abrir este repositório.
|
|
239
|
+
|
|
240
|
+
SUBSTITUIÇÃO TOTAL, não acréscimo. O conteúdo enviado passa a ser o artefato inteiro — para acrescentar um parágrafo, use \`ler_artefato\` primeiro, junte o que falta e mande o texto completo. Mandar só o trecho novo APAGA o resto.
|
|
241
|
+
|
|
242
|
+
Conteúdo vazio é válido e significa apagar o artefato.`,
|
|
243
|
+
inputSchema: z.object({
|
|
244
|
+
tipo: z
|
|
245
|
+
.enum(["politica", "briefing"])
|
|
246
|
+
.describe("Qual dos dois artefatos escrever."),
|
|
247
|
+
conteudo: z
|
|
248
|
+
.string()
|
|
249
|
+
.describe("O markdown completo do artefato, não um trecho. Limite de 200 mil caracteres."),
|
|
250
|
+
}),
|
|
251
|
+
}, async ({ tipo, conteudo }) => {
|
|
252
|
+
try {
|
|
253
|
+
const r = await escreveArtefato(raiz, tipo, conteudo);
|
|
254
|
+
return texto(`${r.criou ? "Criado" : "Atualizado"} o ${r.artefato} — ${r.tamanho} caractere(s).` +
|
|
255
|
+
(tipo === "politica"
|
|
256
|
+
? " A política nova vale a partir da próxima sessão."
|
|
257
|
+
: ""));
|
|
258
|
+
}
|
|
259
|
+
catch (erro) {
|
|
260
|
+
return falha(erro);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return server;
|
|
264
|
+
}
|
|
265
|
+
serveStdio(criaServidor);
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dd-harness-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Servidor MCP do dd-harness: o agente consulta e grava memoria como ferramenta, sem passar por arquivo.",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"author": "Diego Dias",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"claude-code",
|
|
10
|
+
"mcp",
|
|
11
|
+
"model-context-protocol",
|
|
12
|
+
"ai-agents",
|
|
13
|
+
"memory"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://dd-harness.vercel.app",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/diegodias93/dd-harness-online.git",
|
|
19
|
+
"directory": "packages/mcp"
|
|
20
|
+
},
|
|
21
|
+
"//bin": "`dist/mcp/src/` e nao `dist/` porque o build abrange dois diretorios (este pacote e o fonte do CLI), e o `rootDir` comum e `packages/`. Conferir com `node dist/mcp/src/index.js` antes de publicar: caminho errado aqui instala e quebra no primeiro uso.",
|
|
22
|
+
"bin": {
|
|
23
|
+
"dd-harness-mcp": "dist/mcp/src/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
33
|
+
"dd-harness": "workspace:*",
|
|
34
|
+
"zod": "^4.5.4"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"typecheck": "tsc -p . --noEmit",
|
|
38
|
+
"build": "tsc -p tsconfig.build.json",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
}
|
|
41
|
+
}
|