dd-harness-mcp 0.16.0 → 0.17.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 +29 -25
- package/dist/cli/src/artefato.js +6 -2
- package/dist/cli/src/cinto.js +29 -10
- package/dist/cli/src/config.js +35 -6
- package/dist/cli/src/curar.js +9 -4
- package/dist/cli/src/diagnostico.js +92 -0
- package/dist/cli/src/edicoes.js +52 -0
- package/dist/cli/src/escreve-config.js +13 -3
- package/dist/cli/src/estado-local.js +33 -0
- package/dist/cli/src/git.js +67 -0
- package/dist/cli/src/gravar.js +5 -15
- package/dist/cli/src/hosts.js +129 -0
- package/dist/cli/src/index.js +219 -102
- package/dist/cli/src/pasta.js +2 -2
- package/dist/cli/src/politica.js +4 -2
- package/dist/cli/src/projeto.js +2 -2
- package/dist/cli/src/regras-de-commit.js +15 -0
- package/dist/cli/src/roadmap.js +3 -3
- package/dist/cli/src/sessao.js +125 -0
- package/dist/cli/src/skill.js +101 -30
- package/dist/cli/src/skills-iniciais.js +407 -18
- package/dist/cli/src/versao.js +18 -0
- package/dist/cli/src/worker.js +21 -10
- package/dist/mcp/src/index.js +12 -12
- package/package.json +2 -2
package/dist/cli/src/api.js
CHANGED
|
@@ -20,44 +20,39 @@ export function cabecalhos(token, comCorpo = false) {
|
|
|
20
20
|
? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
21
21
|
: { Authorization: `Bearer ${token}` };
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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"]);
|
|
23
|
+
/** Retry limitado a leituras; erro de transporte em escrita deixa resultado incerto. */
|
|
24
|
+
// Mesmo PUT/DELETE podem ter auditoria, triggers ou conflito após a primeira aplicação.
|
|
25
|
+
// Só leituras são repetidas automaticamente; erro de transporte não prova ausência de commit.
|
|
26
|
+
const LEITURAS = new Set(["GET", "HEAD"]);
|
|
43
27
|
const espera = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
28
|
export async function pede(url, init = {}, tentativas = 3) {
|
|
45
29
|
const metodo = (init.method ?? "GET").toUpperCase();
|
|
46
|
-
const podeRepetirErroDoServidor =
|
|
30
|
+
const podeRepetirErroDoServidor = LEITURAS.has(metodo);
|
|
31
|
+
if (!Number.isInteger(tentativas) || tentativas < 1)
|
|
32
|
+
throw new Error("tentativas deve ser inteiro positivo");
|
|
47
33
|
let ultimoErro;
|
|
48
34
|
for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
|
|
49
35
|
try {
|
|
50
|
-
const
|
|
36
|
+
const timeout = AbortSignal.timeout(10000);
|
|
37
|
+
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
38
|
+
const resposta = await fetch(url, { ...init, signal });
|
|
39
|
+
if (resposta.status >= 500 && !podeRepetirErroDoServidor) {
|
|
40
|
+
await resposta.body?.cancel();
|
|
41
|
+
throw new Error(`HTTP ${resposta.status}: o servidor pode ter aplicado a escrita antes de falhar`);
|
|
42
|
+
}
|
|
51
43
|
if (resposta.status >= 500 && podeRepetirErroDoServidor && tentativa < tentativas) {
|
|
44
|
+
await resposta.body?.cancel();
|
|
52
45
|
await espera(tentativa * 400);
|
|
53
46
|
continue;
|
|
54
47
|
}
|
|
55
48
|
return resposta;
|
|
56
49
|
}
|
|
57
50
|
catch (erro) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
if (!podeRepetirErroDoServidor) {
|
|
52
|
+
throw new Error(`resultado incerto de ${metodo}: não houve confirmação confiável e a escrita pode ter sido aplicada. Consulte o estado antes de tentar novamente.`, { cause: erro });
|
|
53
|
+
}
|
|
54
|
+
if (init.signal?.aborted)
|
|
55
|
+
throw erro;
|
|
61
56
|
ultimoErro = erro;
|
|
62
57
|
if (tentativa === tentativas)
|
|
63
58
|
break;
|
|
@@ -67,3 +62,12 @@ export async function pede(url, init = {}, tentativas = 3) {
|
|
|
67
62
|
const detalhe = ultimoErro instanceof Error ? ultimoErro.message : String(ultimoErro);
|
|
68
63
|
throw new Error(`não consegui falar com o serviço depois de ${tentativas} tentativas: ${detalhe}`);
|
|
69
64
|
}
|
|
65
|
+
/** A conexão também pode cair depois dos headers, enquanto o corpo é consumido. */
|
|
66
|
+
export async function jsonDaEscrita(resposta) {
|
|
67
|
+
try {
|
|
68
|
+
return await resposta.json();
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
throw new Error("resultado incerto: a resposta da escrita ficou incompleta. Consulte o estado antes de tentar novamente.", { cause });
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/cli/src/artefato.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
1
|
+
import { jsonDaEscrita, cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
2
|
+
import { invalidaContexto } from "./estado-local.js";
|
|
2
3
|
/**
|
|
3
4
|
* O `GET` devolve o payload inteiro (politica, briefing, pastas e memorias); aqui so o
|
|
4
5
|
* artefato pedido interessa.
|
|
@@ -38,5 +39,8 @@ export async function escreveArtefato(raiz, tipo, conteudo) {
|
|
|
38
39
|
});
|
|
39
40
|
if (!resposta.ok)
|
|
40
41
|
await recusa(resposta);
|
|
41
|
-
|
|
42
|
+
await invalidaContexto(raiz).catch(() => {
|
|
43
|
+
throw new Error("Artefato aceito pela API, mas a sessão local não pôde ser invalidada. Reabra a sessão antes de trabalhar.");
|
|
44
|
+
});
|
|
45
|
+
return (await jsonDaEscrita(resposta));
|
|
42
46
|
}
|
package/dist/cli/src/cinto.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { estadoDoRepo, escreveAtomico } from "./estado-local.js";
|
|
4
|
+
import { leConfigDoRepo, leToken } from "./config.js";
|
|
2
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
6
|
import { memoriasNoConteudo } from "./diff.js";
|
|
4
7
|
/**
|
|
@@ -23,7 +26,16 @@ import { memoriasNoConteudo } from "./diff.js";
|
|
|
23
26
|
*/
|
|
24
27
|
/** Onde o cache vive. Fora do repositorio: nada do dd-harness volta a morar em disco versionado. */
|
|
25
28
|
export function caminhoDoCache(raiz) {
|
|
26
|
-
return join(raiz, "
|
|
29
|
+
return join(estadoDoRepo(raiz), "ancoras.json");
|
|
30
|
+
}
|
|
31
|
+
async function identidade(raiz) {
|
|
32
|
+
const config = await leConfigDoRepo(raiz).catch(() => null);
|
|
33
|
+
return createHash("sha256").update(JSON.stringify([config, config ? await leToken(config.api) : null])).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
export async function invalidaCache(raiz) {
|
|
36
|
+
await rm(caminhoDoCache(raiz), { force: true }).catch(() => {
|
|
37
|
+
throw new Error("Gravação aceita pela API, mas o cache local não pôde ser invalidado. Reabra a sessão para reconstruí-lo.");
|
|
38
|
+
});
|
|
27
39
|
}
|
|
28
40
|
/**
|
|
29
41
|
* Acrescenta UMA memoria ao cache, sem esperar a proxima sessao.
|
|
@@ -40,21 +52,23 @@ export function caminhoDoCache(raiz) {
|
|
|
40
52
|
* Idempotente pelo endereco: regravar a mesma memoria substitui a entrada, nunca duplica.
|
|
41
53
|
*/
|
|
42
54
|
export async function acrescentaAoCache(raiz, memoria) {
|
|
43
|
-
|
|
55
|
+
const existente = await leCache(raiz);
|
|
56
|
+
if (!existente && memoria.ancoras.length === 0)
|
|
44
57
|
return;
|
|
45
|
-
const atual =
|
|
58
|
+
const atual = existente ?? { gravado_em: "", memorias: [] };
|
|
46
59
|
const endereco = `${memoria.pasta}/${memoria.slug}`;
|
|
47
60
|
const cache = {
|
|
61
|
+
projeto: await identidade(raiz),
|
|
48
62
|
gravado_em: new Date().toISOString(),
|
|
49
63
|
memorias: [
|
|
50
64
|
...atual.memorias.filter((m) => `${m.pasta}/${m.slug}` !== endereco),
|
|
51
|
-
memoria,
|
|
65
|
+
...(memoria.status === "ativa" && memoria.ancoras.length ? [memoria] : []),
|
|
52
66
|
],
|
|
53
67
|
};
|
|
54
68
|
try {
|
|
55
69
|
const caminho = caminhoDoCache(raiz);
|
|
56
70
|
await mkdir(dirname(caminho), { recursive: true });
|
|
57
|
-
await
|
|
71
|
+
await escreveAtomico(caminho, `${JSON.stringify(cache)}\n`);
|
|
58
72
|
}
|
|
59
73
|
catch {
|
|
60
74
|
// Mesmo motivo do `guardaCache`: cache e otimizacao, nao contrato.
|
|
@@ -63,6 +77,7 @@ export async function acrescentaAoCache(raiz, memoria) {
|
|
|
63
77
|
/** Guarda as ancoras do Brain para o hook consultar sem rede. Falha em silencio: cache e otimizacao, nao contrato. */
|
|
64
78
|
export async function guardaCache(raiz, brain) {
|
|
65
79
|
const cache = {
|
|
80
|
+
projeto: await identidade(raiz),
|
|
66
81
|
gravado_em: new Date().toISOString(),
|
|
67
82
|
memorias: brain.memorias
|
|
68
83
|
.filter((m) => m.status === "ativa" && m.ancoras.length > 0)
|
|
@@ -79,7 +94,7 @@ export async function guardaCache(raiz, brain) {
|
|
|
79
94
|
try {
|
|
80
95
|
const caminho = caminhoDoCache(raiz);
|
|
81
96
|
await mkdir(dirname(caminho), { recursive: true });
|
|
82
|
-
await
|
|
97
|
+
await escreveAtomico(caminho, `${JSON.stringify(cache)}\n`);
|
|
83
98
|
}
|
|
84
99
|
catch {
|
|
85
100
|
// Sem cache o hook cala, e o PULL continua funcionando. Nao vale falhar a sessao.
|
|
@@ -87,7 +102,10 @@ export async function guardaCache(raiz, brain) {
|
|
|
87
102
|
}
|
|
88
103
|
export async function leCache(raiz) {
|
|
89
104
|
try {
|
|
90
|
-
|
|
105
|
+
const cache = JSON.parse(await readFile(caminhoDoCache(raiz), "utf8"));
|
|
106
|
+
if (cache.projeto !== await identidade(raiz) || !Array.isArray(cache.memorias))
|
|
107
|
+
return null;
|
|
108
|
+
return cache;
|
|
91
109
|
}
|
|
92
110
|
catch {
|
|
93
111
|
return null;
|
|
@@ -106,7 +124,8 @@ export async function leCache(raiz) {
|
|
|
106
124
|
* nao viu o aviso de hoje.
|
|
107
125
|
*/
|
|
108
126
|
async function jaAvisou(raiz, sessao, enderecos) {
|
|
109
|
-
const
|
|
127
|
+
const chave = createHash("sha256").update(sessao + await identidade(raiz)).digest("hex");
|
|
128
|
+
const caminho = join(estadoDoRepo(raiz), `avisos-${chave}.json`);
|
|
110
129
|
let vistos = [];
|
|
111
130
|
try {
|
|
112
131
|
vistos = JSON.parse(await readFile(caminho, "utf8"));
|
|
@@ -134,7 +153,7 @@ async function jaAvisou(raiz, sessao, enderecos) {
|
|
|
134
153
|
*/
|
|
135
154
|
export function caminhoRelativo(raiz, arquivo) {
|
|
136
155
|
const rel = relative(resolve(raiz), resolve(arquivo));
|
|
137
|
-
if (!rel || rel.startsWith(
|
|
156
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || /^[A-Za-z]:/.test(rel))
|
|
138
157
|
return null;
|
|
139
158
|
return rel.split(sep).join("/");
|
|
140
159
|
}
|
package/dist/cli/src/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { diretorioDoUsuario } from "./estado-local.js";
|
|
5
5
|
/**
|
|
6
6
|
* O servico publicado e o padrao: um repositorio que so declara `tenant` e `projeto` ja
|
|
7
7
|
* fala com quem existe. Para desenvolver contra a maquina local, ponha `api` explicito no
|
|
@@ -30,8 +30,37 @@ export async function leConfigDoRepo(raiz) {
|
|
|
30
30
|
pasta: lido.pasta ?? PADRAO.pasta,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/** Sobe apenas até a raiz git; não herda outro projeto acima de um worktree. */
|
|
34
|
+
export async function achaRaiz(inicio = process.env.DD_HARNESS_ROOT ?? process.cwd()) {
|
|
35
|
+
let atual = resolve(inicio);
|
|
36
|
+
const { access } = await import("node:fs/promises");
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (await access(join(atual, CAMINHO_CONFIG)).then(() => true, () => false))
|
|
39
|
+
return atual;
|
|
40
|
+
if (await access(join(atual, ".git")).then(() => true, () => false))
|
|
41
|
+
break;
|
|
42
|
+
const pai = dirname(atual);
|
|
43
|
+
if (pai === atual)
|
|
44
|
+
break;
|
|
45
|
+
atual = pai;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`não encontrei ${CAMINHO_CONFIG} a partir de ${resolve(inicio)}. Configure este projeto com dd-harness start.`);
|
|
48
|
+
}
|
|
49
|
+
/** Uma configuração copiada de outro checkout nunca pode direcionar operações silenciosamente. */
|
|
50
|
+
export async function raizDoMcp() {
|
|
51
|
+
const explicita = process.env.DD_HARNESS_ROOT;
|
|
52
|
+
const atual = await achaRaiz(process.cwd()).catch(() => null);
|
|
53
|
+
if (!explicita)
|
|
54
|
+
return atual ?? process.cwd();
|
|
55
|
+
const raiz = await achaRaiz(explicita);
|
|
56
|
+
const normaliza = (p) => process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p);
|
|
57
|
+
if (atual && normaliza(atual) !== normaliza(raiz)) {
|
|
58
|
+
throw new Error("DD_HARNESS_ROOT aponta para outro checkout/projeto. Corrija a raiz na configuração MCP deste host e reinicie-o.");
|
|
59
|
+
}
|
|
60
|
+
return raiz;
|
|
61
|
+
}
|
|
62
|
+
const arquivoDeCredenciais = () => join(diretorioDoUsuario(), "credentials.json");
|
|
63
|
+
const arquivoDeConfigDaMaquina = () => join(diretorioDoUsuario(), "config.json");
|
|
35
64
|
export async function leConfigDaMaquina() {
|
|
36
65
|
try {
|
|
37
66
|
const cru = await readFile(arquivoDeConfigDaMaquina(), "utf8");
|
|
@@ -43,7 +72,7 @@ export async function leConfigDaMaquina() {
|
|
|
43
72
|
}
|
|
44
73
|
export async function guardaConfigDaMaquina(parcial) {
|
|
45
74
|
const caminho = arquivoDeConfigDaMaquina();
|
|
46
|
-
await mkdir(
|
|
75
|
+
await mkdir(diretorioDoUsuario(), { recursive: true });
|
|
47
76
|
const atual = await leConfigDaMaquina();
|
|
48
77
|
const mesclado = { ...atual, ...parcial };
|
|
49
78
|
await writeFile(caminho, `${JSON.stringify(mesclado, null, 2)}\n`, "utf8");
|
|
@@ -65,7 +94,7 @@ export async function leToken(api) {
|
|
|
65
94
|
}
|
|
66
95
|
export async function guardaToken(api, token) {
|
|
67
96
|
const caminho = arquivoDeCredenciais();
|
|
68
|
-
await mkdir(
|
|
97
|
+
await mkdir(diretorioDoUsuario(), { recursive: true });
|
|
69
98
|
let mapa = {};
|
|
70
99
|
try {
|
|
71
100
|
mapa = JSON.parse(await readFile(caminho, "utf8"));
|
package/dist/cli/src/curar.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { invalidaCache } from "./cinto.js";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
3
|
+
import { jsonDaEscrita, cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
3
4
|
import { interpreta } from "./gravar.js";
|
|
4
5
|
/**
|
|
5
6
|
* A memoria inteira, no markdown que `gravar` e `editar` consomem.
|
|
@@ -73,6 +74,7 @@ export async function edita(raiz, caminho) {
|
|
|
73
74
|
});
|
|
74
75
|
if (!resposta.ok)
|
|
75
76
|
await recusa(resposta);
|
|
77
|
+
await invalidaCache(raiz);
|
|
76
78
|
return { endereco, ancoras: memoria.ancoras.length };
|
|
77
79
|
}
|
|
78
80
|
export async function arquiva(raiz, endereco, opcoes) {
|
|
@@ -89,7 +91,8 @@ export async function arquiva(raiz, endereco, opcoes) {
|
|
|
89
91
|
});
|
|
90
92
|
if (!resposta.ok)
|
|
91
93
|
await recusa(resposta);
|
|
92
|
-
|
|
94
|
+
await invalidaCache(raiz);
|
|
95
|
+
const lido = (await jsonDaEscrita(resposta));
|
|
93
96
|
return lido;
|
|
94
97
|
}
|
|
95
98
|
/**
|
|
@@ -108,7 +111,8 @@ export async function promove(raiz, endereco, global) {
|
|
|
108
111
|
});
|
|
109
112
|
if (!resposta.ok)
|
|
110
113
|
await recusa(resposta);
|
|
111
|
-
|
|
114
|
+
await invalidaCache(raiz);
|
|
115
|
+
return (await jsonDaEscrita(resposta));
|
|
112
116
|
}
|
|
113
117
|
/**
|
|
114
118
|
* Apaga de verdade, em cascata — ancoras, deriva medida, vinculos, tudo.
|
|
@@ -140,5 +144,6 @@ export async function apaga(raiz, endereco, confirmacao) {
|
|
|
140
144
|
}
|
|
141
145
|
if (!resposta.ok)
|
|
142
146
|
await recusa(resposta);
|
|
143
|
-
|
|
147
|
+
await invalidaCache(raiz);
|
|
148
|
+
return (await jsonDaEscrita(resposta));
|
|
144
149
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { achaRaiz, leConfigDoRepo, leToken } from "./config.js";
|
|
5
|
+
import { buscaPolitica } from "./politica.js";
|
|
6
|
+
export async function handshakeMcp(raiz, comando = "npx", args = ["-y", "dd-harness-mcp@latest"]) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const filho = spawn(comando, args, { cwd: raiz, windowsHide: true,
|
|
9
|
+
shell: process.platform === "win32" && comando === "npx", stdio: ["pipe", "pipe", "pipe"],
|
|
10
|
+
env: { ...process.env, DD_HARNESS_ROOT: raiz } });
|
|
11
|
+
let buffer = "", terminou = false, versao = "desconhecida";
|
|
12
|
+
const fim = (erro, resultado) => {
|
|
13
|
+
if (terminou)
|
|
14
|
+
return;
|
|
15
|
+
terminou = true;
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
filho.stdin.end();
|
|
18
|
+
filho.kill();
|
|
19
|
+
if (erro)
|
|
20
|
+
reject(erro);
|
|
21
|
+
else
|
|
22
|
+
resolve(resultado);
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(() => fim(new Error("MCP não completou initialize/tools/list em 30 segundos")), 30000);
|
|
25
|
+
filho.once("error", () => fim(new Error("Não foi possível iniciar o executável MCP")));
|
|
26
|
+
filho.once("exit", () => { if (!terminou)
|
|
27
|
+
fim(new Error("MCP encerrou antes de listar ferramentas")); });
|
|
28
|
+
filho.stdin.on("error", () => fim(new Error("MCP fechou stdin antes do handshake")));
|
|
29
|
+
filho.stderr.resume(); // Saída do subprocesso pode conter ambiente; não a ecoar.
|
|
30
|
+
const envia = (v) => filho.stdin.write(JSON.stringify(v) + "\n");
|
|
31
|
+
filho.stdout.on("data", chunk => {
|
|
32
|
+
buffer += chunk;
|
|
33
|
+
if (buffer.length > 2000000)
|
|
34
|
+
return fim(new Error("Resposta MCP excedeu o limite do diagnóstico"));
|
|
35
|
+
for (;;) {
|
|
36
|
+
const pos = buffer.indexOf("\n");
|
|
37
|
+
if (pos < 0)
|
|
38
|
+
break;
|
|
39
|
+
const linha = buffer.slice(0, pos);
|
|
40
|
+
buffer = buffer.slice(pos + 1);
|
|
41
|
+
let msg;
|
|
42
|
+
try {
|
|
43
|
+
msg = JSON.parse(linha);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return fim(new Error("MCP imprimiu texto fora do protocolo em stdout"));
|
|
47
|
+
}
|
|
48
|
+
if (msg.error)
|
|
49
|
+
return fim(new Error("MCP recusou initialize/tools/list"));
|
|
50
|
+
if (msg.id === 1) {
|
|
51
|
+
versao = msg.result?.serverInfo?.version ?? versao;
|
|
52
|
+
envia({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
53
|
+
envia({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
54
|
+
}
|
|
55
|
+
else if (msg.id === 2) {
|
|
56
|
+
const ferramentas = msg.result?.tools?.map((t) => t.name);
|
|
57
|
+
if (!Array.isArray(ferramentas))
|
|
58
|
+
return fim(new Error("tools/list não devolveu ferramentas"));
|
|
59
|
+
fim(undefined, { versao, ferramentas });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
envia({ jsonrpc: "2.0", id: 1, method: "initialize", params: {
|
|
64
|
+
protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "dd-harness-diagnostico", version: "1.0.0" },
|
|
65
|
+
} });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export async function diagnostico(inicio, testarMcp = false) {
|
|
69
|
+
const raiz = await achaRaiz(inicio);
|
|
70
|
+
const c = await leConfigDoRepo(raiz);
|
|
71
|
+
const linhas = [`Raiz: ${raiz}`, `Projeto: ${c.tenant}/${c.projeto}`, `Node: ${process.version}`,
|
|
72
|
+
`Credencial: ${await leToken(c.api) ? "presente (valor omitido)" : "ausente"}`];
|
|
73
|
+
for (const [host, config, hook] of [
|
|
74
|
+
["Claude Code", ".mcp.json", ".claude/settings.json"],
|
|
75
|
+
["Codex", ".codex/config.toml", ".codex/hooks.json"],
|
|
76
|
+
["Antigravity", ".agents/mcp_config.json", ".agents/hooks.json"],
|
|
77
|
+
]) {
|
|
78
|
+
const m = await readFile(join(raiz, config), "utf8").catch(() => "");
|
|
79
|
+
const h = await readFile(join(raiz, hook), "utf8").catch(() => "");
|
|
80
|
+
linhas.push(`${host}: MCP ${m.includes("dd-harness") ? "declarado" : "ausente"}; ` +
|
|
81
|
+
`hooks ${h.includes("dd-harness guarda") && h.includes("dd-harness sessao") ? "declarados" : "ausentes/legados"}; ativação no host requer verificação.`);
|
|
82
|
+
if (m.includes("dd-harness") && !m.includes(JSON.stringify(raiz)))
|
|
83
|
+
linhas.push(`${host}: confira DD_HARNESS_ROOT; a raiz explícita atual não foi reconhecida.`);
|
|
84
|
+
}
|
|
85
|
+
const politica = await buscaPolitica(raiz);
|
|
86
|
+
linhas.push(`Contexto: ${politica.estado}${politica.estado === "inalcancavel" ? " — " + politica.motivo : ""}`);
|
|
87
|
+
if (testarMcp) {
|
|
88
|
+
const m = await handshakeMcp(raiz);
|
|
89
|
+
linhas.push(`MCP ${m.versao}: initialize/tools/list concluídos; ${m.ferramentas.length} ferramentas. Isto não comprova hooks ativos.`);
|
|
90
|
+
}
|
|
91
|
+
return linhas;
|
|
92
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
const texto = (v) => typeof v === "string" ? v : undefined;
|
|
3
|
+
export function normalizaEntrada(host, entrada) {
|
|
4
|
+
if (host === "antigravity") {
|
|
5
|
+
const roots = entrada.workspacePaths;
|
|
6
|
+
if (!Array.isArray(roots) || roots.length !== 1 || typeof roots[0] !== "string") {
|
|
7
|
+
throw new Error("dd-harness exige um workspace inequívoco por sessão; selecione um projeto.");
|
|
8
|
+
}
|
|
9
|
+
const chamada = entrada.toolCall;
|
|
10
|
+
return { raiz: roots[0], sessao: texto(entrada.conversationId) ?? "", ferramenta: chamada?.name ?? "",
|
|
11
|
+
args: chamada?.args ?? {} };
|
|
12
|
+
}
|
|
13
|
+
return { raiz: texto(entrada.cwd) ?? process.cwd(), sessao: texto(entrada.session_id) ?? "",
|
|
14
|
+
ferramenta: texto(entrada.tool_name) ?? "", args: (entrada.tool_input ?? {}) };
|
|
15
|
+
}
|
|
16
|
+
export function edicoesDaChamada(c) {
|
|
17
|
+
const a = c.args;
|
|
18
|
+
if (c.ferramenta === "apply_patch") {
|
|
19
|
+
const patch = texto(a.command) ?? texto(a.patch) ?? "";
|
|
20
|
+
const edicoes = [];
|
|
21
|
+
let atual;
|
|
22
|
+
for (const linha of patch.split(/\r?\n/)) {
|
|
23
|
+
const inicio = /^\*\*\* (Update|Add|Delete) File: (.+)$/.exec(linha);
|
|
24
|
+
if (inicio) {
|
|
25
|
+
atual = { arquivo: resolve(c.raiz, inicio[2]), antes: inicio[1] === "Delete" ? undefined : "", depois: "" };
|
|
26
|
+
edicoes.push(atual);
|
|
27
|
+
}
|
|
28
|
+
else if (atual && linha.startsWith("*** Move to: ")) {
|
|
29
|
+
// Renomear afeta o arquivo inteiro, inclusive trechos fora dos hunks.
|
|
30
|
+
atual.antes = undefined;
|
|
31
|
+
edicoes.push({ arquivo: resolve(c.raiz, linha.slice(13)), origem: atual.arquivo });
|
|
32
|
+
}
|
|
33
|
+
else if (atual && !linha.startsWith("***")) {
|
|
34
|
+
if (atual.antes !== undefined && (linha.startsWith("-") || linha.startsWith(" ")))
|
|
35
|
+
atual.antes += linha.slice(1) + "\n";
|
|
36
|
+
if (linha.startsWith("+") || linha.startsWith(" "))
|
|
37
|
+
atual.depois += linha.slice(1) + "\n";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return edicoes;
|
|
41
|
+
}
|
|
42
|
+
const arquivo = texto(a.file_path) ?? texto(a.TargetFile) ?? texto(a.Path);
|
|
43
|
+
if (!arquivo)
|
|
44
|
+
return [];
|
|
45
|
+
const chunks = a.ReplacementChunks ?? a.edits;
|
|
46
|
+
if (Array.isArray(chunks))
|
|
47
|
+
return chunks.map(chunk => ({ arquivo: resolve(c.raiz, arquivo),
|
|
48
|
+
antes: texto(chunk.TargetContent ?? chunk.old_string), depois: texto(chunk.ReplacementContent ?? chunk.new_string) }));
|
|
49
|
+
return [{ arquivo: resolve(c.raiz, arquivo),
|
|
50
|
+
antes: texto(a.old_string) ?? texto(a.TargetContent),
|
|
51
|
+
depois: texto(a.new_string) ?? texto(a.content) ?? texto(a.ReplacementContent) ?? texto(a.CodeContent) }];
|
|
52
|
+
}
|
|
@@ -112,7 +112,7 @@ export async function escrevePonteiro(raiz, arquivo) {
|
|
|
112
112
|
}
|
|
113
113
|
/** Compatibilidade: `init` ainda chama pelo nome antigo. */
|
|
114
114
|
export const escreveAgents = (raiz) => escrevePonteiro(raiz, "AGENTS.md");
|
|
115
|
-
const SUGESTAO_AGENTS = `## Protocolo do dd-harness
|
|
115
|
+
export const SUGESTAO_AGENTS = `## Protocolo do dd-harness
|
|
116
116
|
|
|
117
117
|
Este projeto tem um protocolo de trabalho que **não está neste arquivo**.
|
|
118
118
|
|
|
@@ -124,6 +124,16 @@ de ler código, responder ou planejar.
|
|
|
124
124
|
avise o usuário e **não modifique nada** até ele resolver.
|
|
125
125
|
- Se ela devolver vazio, este projeto nunca foi briefado — diga isso ao usuário.
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
Leia também o briefing com a mesma ferramenta (tipo briefing). Os dois são obrigatórios.
|
|
128
|
+
|
|
129
|
+
Depois, \`ler_roadmap\`: se houver uma fase **Agora**, é dela que saem os passos
|
|
128
130
|
desta sessão. Lista vazia significa que este projeto não usa roadmap, e isso é
|
|
129
|
-
válido — não crie fase sem o usuário pedir
|
|
131
|
+
válido — não crie fase sem o usuário pedir.
|
|
132
|
+
|
|
133
|
+
E \`listar_skills\`: são os procedimentos deste projeto. **Invoque a que couber
|
|
134
|
+
ANTES de fazer o trabalho, não depois** — skill lida no fim vira revisão do que
|
|
135
|
+
já saiu errado, e é tarde.
|
|
136
|
+
|
|
137
|
+
Vale mesmo quando o pedido parece pequeno: "é só um ajuste" é exatamente a
|
|
138
|
+
frase que antecede pular o procedimento. A política diz quais são obrigatórias
|
|
139
|
+
e quando.`;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, writeFile, rm, readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
/** Override explícito para testes e instalações isoladas; nunca troca HOME do processo. */
|
|
6
|
+
export const diretorioDoUsuario = () => process.env.DD_HARNESS_HOME ?? join(homedir(), ".dd-harness");
|
|
7
|
+
export function estadoDoRepo(raiz) {
|
|
8
|
+
const caminho = resolve(raiz);
|
|
9
|
+
const chave = createHash("sha256").update(process.platform === "win32" ? caminho.toLowerCase() : caminho).digest("hex");
|
|
10
|
+
return join(diretorioDoUsuario(), "repos", chave);
|
|
11
|
+
}
|
|
12
|
+
export async function revisaoDoContexto(raiz) {
|
|
13
|
+
return readFile(join(estadoDoRepo(raiz), "contexto-revisao"), "utf8").catch((e) => {
|
|
14
|
+
if (e.code === "ENOENT")
|
|
15
|
+
return "";
|
|
16
|
+
throw e;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function invalidaContexto(raiz) {
|
|
20
|
+
await escreveAtomico(join(estadoDoRepo(raiz), "contexto-revisao"), randomUUID());
|
|
21
|
+
}
|
|
22
|
+
/** Leitores veem o JSON anterior ou o novo, nunca um arquivo parcialmente escrito. */
|
|
23
|
+
export async function escreveAtomico(caminho, conteudo) {
|
|
24
|
+
await mkdir(dirname(caminho), { recursive: true });
|
|
25
|
+
const temporario = `${caminho}.${randomUUID()}.tmp`;
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(temporario, conteudo, { encoding: "utf8", mode: 0o600 });
|
|
28
|
+
await rename(temporario, caminho);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await rm(temporario, { force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const roda = promisify(execFile);
|
|
4
|
+
/** Ja e um repositorio? Responde pelo proprio git, nao pela existencia de `.git` — submodulo e worktree tem `.git` como ARQUIVO, e checar a pasta erraria nos dois. */
|
|
5
|
+
export async function ehRepositorio(raiz) {
|
|
6
|
+
try {
|
|
7
|
+
const { stdout } = await roda("git", ["rev-parse", "--is-inside-work-tree"], { cwd: raiz });
|
|
8
|
+
return stdout.trim() === "true";
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Cria o repositorio, se ainda nao houver.
|
|
16
|
+
*
|
|
17
|
+
* Nao faz commit inicial: o que commitar e decisao de quem esta comecando o projeto, e um
|
|
18
|
+
* commit automatico com os arquivos que o `start` acabou de escrever criaria uma historia
|
|
19
|
+
* que ninguem pediu — inclusive num repositorio que a pessoa talvez queira que comece
|
|
20
|
+
* vazio.
|
|
21
|
+
*
|
|
22
|
+
* Nunca lanca. `start` nao pode falhar porque o git nao esta instalado.
|
|
23
|
+
*/
|
|
24
|
+
export async function iniciaRepositorio(raiz) {
|
|
25
|
+
if (await ehRepositorio(raiz))
|
|
26
|
+
return "ja-era";
|
|
27
|
+
try {
|
|
28
|
+
await roda("git", ["init"], { cwd: raiz });
|
|
29
|
+
return "criado";
|
|
30
|
+
}
|
|
31
|
+
catch (erro) {
|
|
32
|
+
return erro.code === "ENOENT" ? "git-indisponivel" : "falhou";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Escreve o gancho `post-commit`, que devolve a memoria ao code review.
|
|
37
|
+
*
|
|
38
|
+
* Ate aqui ele era IMPRESSO para a pessoa colar, pela regra de que `.git/hooks` nao e
|
|
39
|
+
* nosso. A regra continua valendo para repositorio que ja existia — mas num que o
|
|
40
|
+
* proprio `start` acabou de criar nao ha nada de ninguem para preservar, e imprimir
|
|
41
|
+
* instrucao que so vale se alguem copiar e colar e o mesmo "rode X depois" que a rodada
|
|
42
|
+
* 006 mostrou que ninguem segue.
|
|
43
|
+
*
|
|
44
|
+
* Nunca sobrescreve um gancho existente: ali ha trabalho de outra pessoa.
|
|
45
|
+
*/
|
|
46
|
+
export async function escreveGanchoDeCommit(raiz) {
|
|
47
|
+
const { join } = await import("node:path");
|
|
48
|
+
const { readFile, writeFile, chmod } = await import("node:fs/promises");
|
|
49
|
+
const caminho = join(raiz, ".git", "hooks", "post-commit");
|
|
50
|
+
try {
|
|
51
|
+
await readFile(caminho, "utf8");
|
|
52
|
+
return "ja-tinha";
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Sem gancho: escreve.
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
// `|| true` porque aviso nao pode falhar um commit que ja aconteceu — o gancho roda
|
|
59
|
+
// DEPOIS, e sair diferente de zero aqui so polui a saida de quem commitou.
|
|
60
|
+
await writeFile(caminho, '#!/bin/sh\n# dd-harness: avisa quais memórias falam do que este commit mudou.\ndd-harness check --commit "$(git rev-parse HEAD)" || true\n', "utf8");
|
|
61
|
+
await chmod(caminho, 0o755);
|
|
62
|
+
return "criado";
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return "falhou";
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/cli/src/gravar.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { invalidaCache } from "./cinto.js";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { recusaPorAcentuacao } from "./acentuacao.js";
|
|
3
|
-
import { cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
4
|
-
import { acrescentaAoCache } from "./cinto.js";
|
|
4
|
+
import { jsonDaEscrita, cabecalhos, credencial, pede, recusa } from "./api.js";
|
|
5
5
|
const OBRIGATORIOS = ["name", "titulo", "description", "pasta"];
|
|
6
6
|
/**
|
|
7
7
|
* Frontmatter simples: `chave: valor` por linha. Sem lib de YAML — nao ha aninhamento.
|
|
@@ -188,19 +188,9 @@ export async function grava(raiz, caminho) {
|
|
|
188
188
|
});
|
|
189
189
|
if (!resposta.ok)
|
|
190
190
|
await recusa(resposta);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
// e justamente no fluxo mais comum: gravar a decisao e, minutos depois, alguem tentar
|
|
195
|
-
// desfazer o que ela guarda.
|
|
196
|
-
await acrescentaAoCache(raiz, {
|
|
197
|
-
pasta: memoria.pasta,
|
|
198
|
-
slug: memoria.slug,
|
|
199
|
-
titulo: memoria.titulo,
|
|
200
|
-
resumo: memoria.resumo,
|
|
201
|
-
status: "ativa",
|
|
202
|
-
ancoras: memoria.ancoras.map((valor) => ({ tipo: "caminho", valor, sha: null })),
|
|
203
|
-
});
|
|
191
|
+
await invalidaCache(raiz);
|
|
192
|
+
const { endereco } = (await jsonDaEscrita(resposta));
|
|
193
|
+
// A próxima edição atualiza o cache inteiro, incluindo outras memórias e globais.
|
|
204
194
|
return {
|
|
205
195
|
endereco,
|
|
206
196
|
ancoras: memoria.ancoras.length,
|