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,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { check, status } from "./check.js";
|
|
3
|
+
import { leConfigDoRepo, guardaToken } from "./config.js";
|
|
4
|
+
import { grava } from "./gravar.js";
|
|
5
|
+
import { init, SUGESTAO_MCP } from "./init.js";
|
|
6
|
+
import { LINHA_DE_IMPORT } from "./materializa.js";
|
|
7
|
+
import { busca } from "./buscar.js";
|
|
8
|
+
import { arquiva, edita } from "./curar.js";
|
|
9
|
+
import { criaPasta } from "./pasta.js";
|
|
10
|
+
import { criaProjeto } from "./projeto.js";
|
|
11
|
+
import { sync } from "./sync.js";
|
|
12
|
+
/**
|
|
13
|
+
* `dd-harness` — o cliente que materializa os artefatos no repositorio.
|
|
14
|
+
*
|
|
15
|
+
* Sem dependencia de proposito: `fetch`, `crypto` e `fs` sao do Node, e o molde original
|
|
16
|
+
* ja seguia a regra de validador determinístico sem dependencia. Um CLI que vive pinado
|
|
17
|
+
* em repositorio alheio tem que envelhecer bem, e cada dependencia e uma chance de nao
|
|
18
|
+
* envelhecer.
|
|
19
|
+
*/
|
|
20
|
+
const AJUDA = `dd-harness — materializa política, briefing e Brain no repositório
|
|
21
|
+
|
|
22
|
+
dd-harness login --token <token> [--api <url>]
|
|
23
|
+
guarda a credencial desta máquina
|
|
24
|
+
dd-harness projeto <slug> --nome "<nome>" [--tenant <t>] [--api <url>]
|
|
25
|
+
cria o projeto no serviço (antes do init)
|
|
26
|
+
dd-harness init --tenant <t> --projeto <p> [--api <url>]
|
|
27
|
+
prepara o repositório (config + CLAUDE.md)
|
|
28
|
+
dd-harness pasta <slug> --definicao "o que entra e o que não entra"
|
|
29
|
+
cria a pasta que o gravar exige
|
|
30
|
+
dd-harness gravar <arquivo.md> registra uma memória a partir de um markdown
|
|
31
|
+
dd-harness editar <arquivo.md> corrige o que já está gravado
|
|
32
|
+
dd-harness arquivar <pasta>/<slug> --motivo <obsoleta|incorreta|fora_dos_filtros>
|
|
33
|
+
[--substituida-por <pasta>/<slug>]
|
|
34
|
+
tira de circulação sem apagar
|
|
35
|
+
dd-harness buscar "<pergunta>" acha memória por relevância, não por arquivo
|
|
36
|
+
dd-harness sync escreve os artefatos em disco
|
|
37
|
+
dd-harness check [--commit <sha>] mede as âncoras e reporta a deriva
|
|
38
|
+
dd-harness status só lê: o tamanho do Brain e o que espera julgamento
|
|
39
|
+
dd-harness --help
|
|
40
|
+
|
|
41
|
+
O gerado vive em dd-harness/. Na raiz fica só o seu CLAUDE.md, que importa a
|
|
42
|
+
política com a linha ${LINHA_DE_IMPORT}
|
|
43
|
+
`;
|
|
44
|
+
/** O aviso existe porque o import falha em silêncio — foi medido, não suposto. */
|
|
45
|
+
function avisaSobreOPonteiro(ponteiro) {
|
|
46
|
+
if (ponteiro === "ok" || ponteiro === "sem-politica")
|
|
47
|
+
return;
|
|
48
|
+
// O import pendurado e o inverso dos outros dois: a linha esta la, o alvo e que nao
|
|
49
|
+
// existe. Dizer "acrescente a linha" aqui mandaria a pessoa para o lugar errado.
|
|
50
|
+
if (ponteiro === "aponta-para-o-vazio") {
|
|
51
|
+
console.error([
|
|
52
|
+
"",
|
|
53
|
+
`AVISO: o CLAUDE.md importa ${LINHA_DE_IMPORT}, mas não há política no serviço.`,
|
|
54
|
+
"O arquivo apontado não existe, e import quebrado falha em silêncio: a sessão abre",
|
|
55
|
+
"sem protocolo e nada avisa.",
|
|
56
|
+
"",
|
|
57
|
+
"Escreva a política do projeto no serviço, ou tire a linha do CLAUDE.md.",
|
|
58
|
+
].join("\n"));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const motivo = ponteiro === "sem-claude-md"
|
|
62
|
+
? "não há CLAUDE.md na raiz"
|
|
63
|
+
: "o CLAUDE.md da raiz não importa a política";
|
|
64
|
+
console.error([
|
|
65
|
+
"",
|
|
66
|
+
`AVISO: ${motivo}.`,
|
|
67
|
+
"A política existe no serviço e está em disco, mas não chega à sessão: o import",
|
|
68
|
+
"ausente falha em silêncio, e a sessão abre sem protocolo sem avisar ninguém.",
|
|
69
|
+
"",
|
|
70
|
+
`Acrescente esta linha ao CLAUDE.md da raiz: ${LINHA_DE_IMPORT}`,
|
|
71
|
+
"Ou rode: dd-harness init --tenant <t> --projeto <p>",
|
|
72
|
+
].join("\n"));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Os ganchos sao IMPRESSOS, nunca instalados. `.git/hooks` nao e versionado e nao e
|
|
76
|
+
* nosso: escrever la dentro sem a pessoa pedir e o mesmo tipo de invasao que
|
|
77
|
+
* sobrescrever o CLAUDE.md dela. Quem cola, decide.
|
|
78
|
+
*/
|
|
79
|
+
const GANCHOS = `
|
|
80
|
+
Opcional — dois ganchos que valem a pena:
|
|
81
|
+
|
|
82
|
+
.git/hooks/post-commit (avisa quais memórias falam do que você mudou)
|
|
83
|
+
#!/bin/sh
|
|
84
|
+
dd-harness check --commit "$(git rev-parse HEAD)" || true
|
|
85
|
+
|
|
86
|
+
.claude/settings.json (na abertura da sessão, o que espera julgamento)
|
|
87
|
+
"hooks": { "SessionStart": [{ "hooks": [{ "type": "command",
|
|
88
|
+
"command": "dd-harness status" }] }] }
|
|
89
|
+
|
|
90
|
+
Os dois terminam em sucesso mesmo com deriva: avisam, não bloqueiam.`;
|
|
91
|
+
function argumento(argv, nome) {
|
|
92
|
+
const i = argv.indexOf(`--${nome}`);
|
|
93
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
94
|
+
}
|
|
95
|
+
async function comandoInit(argv) {
|
|
96
|
+
const tenant = argumento(argv, "tenant");
|
|
97
|
+
const projeto = argumento(argv, "projeto");
|
|
98
|
+
if (!tenant || !projeto) {
|
|
99
|
+
throw new Error("uso: dd-harness init --tenant <t> --projeto <p> [--api <url>]");
|
|
100
|
+
}
|
|
101
|
+
const r = await init(process.cwd(), {
|
|
102
|
+
tenant,
|
|
103
|
+
projeto,
|
|
104
|
+
api: argumento(argv, "api"),
|
|
105
|
+
});
|
|
106
|
+
console.log(r.config === "criada" ? "criado .dd-harness.json" : "mantido .dd-harness.json");
|
|
107
|
+
console.log({
|
|
108
|
+
criado: "criado CLAUDE.md com a linha de import",
|
|
109
|
+
"linha-acrescentada": "ajustado CLAUDE.md — linha de import acrescentada ao seu",
|
|
110
|
+
"ja-tinha-a-linha": "mantido CLAUDE.md — já importava a política",
|
|
111
|
+
}[r.claudeMd]);
|
|
112
|
+
console.log("\nAgora: dd-harness login --token <token> && dd-harness sync");
|
|
113
|
+
if (r.mcp === "ja-declarado") {
|
|
114
|
+
console.log("\nmantido .mcp.json — o servidor dd-harness já está declarado");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
console.log("\nOpcional: as memórias como ferramenta, para o agente buscar e gravar sem" +
|
|
118
|
+
"\nescrever arquivo. Acrescente ao `.mcp.json` da raiz (não escrevo nele: o" +
|
|
119
|
+
"\narquivo é seu e pode declarar outros servidores):\n");
|
|
120
|
+
console.log(SUGESTAO_MCP);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Nao exige `.dd-harness.json`, e isso importa: sem projeto no servico o `init` nao tem o
|
|
124
|
+
* que apontar, criar projeto exige credencial, e credencial exigindo config fechava um
|
|
125
|
+
* ciclo sem entrada — no dia um de um repositorio novo, nenhum dos tres rodava. O `--api`
|
|
126
|
+
* resolve; com config no repo, ela preenche.
|
|
127
|
+
*/
|
|
128
|
+
async function comandoLogin(argv) {
|
|
129
|
+
const token = argumento(argv, "token");
|
|
130
|
+
if (!token)
|
|
131
|
+
throw new Error("uso: dd-harness login --token <token> [--api <url>]");
|
|
132
|
+
const daLinha = argumento(argv, "api");
|
|
133
|
+
const doRepo = daLinha ? null : await leConfigDoRepo(process.cwd()).catch(() => null);
|
|
134
|
+
const api = (daLinha ?? doRepo?.api ?? "https://dd-harness.vercel.app").replace(/\/$/, "");
|
|
135
|
+
const caminho = await guardaToken(api, token);
|
|
136
|
+
console.log(`credencial de ${api} guardada em ${caminho}`);
|
|
137
|
+
}
|
|
138
|
+
async function comandoEditar(argv) {
|
|
139
|
+
const caminho = argv[0];
|
|
140
|
+
if (!caminho || caminho.startsWith("-")) {
|
|
141
|
+
throw new Error("uso: dd-harness editar <arquivo.md>");
|
|
142
|
+
}
|
|
143
|
+
const r = await edita(process.cwd(), caminho);
|
|
144
|
+
console.log(`editado ${r.endereco}`);
|
|
145
|
+
console.log(` ${r.ancoras} âncora(s). Rode \`dd-harness sync\` para materializar.`);
|
|
146
|
+
}
|
|
147
|
+
const MOTIVOS = ["obsoleta", "incorreta", "fora_dos_filtros"];
|
|
148
|
+
async function comandoArquivar(argv) {
|
|
149
|
+
const endereco = argv[0];
|
|
150
|
+
const motivo = argumento(argv, "motivo");
|
|
151
|
+
if (!endereco || endereco.startsWith("-") || !endereco.includes("/")) {
|
|
152
|
+
throw new Error("uso: dd-harness arquivar <pasta>/<slug> --motivo <obsoleta|incorreta|fora_dos_filtros>");
|
|
153
|
+
}
|
|
154
|
+
// Lista fechada tambem no cliente: errar o motivo aqui devolve a lista, em vez de um
|
|
155
|
+
// 400 do servidor com a mesma informacao mais longe de quem digitou.
|
|
156
|
+
if (!motivo || !MOTIVOS.includes(motivo)) {
|
|
157
|
+
throw new Error(`--motivo precisa ser um de: ${MOTIVOS.join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
const r = await arquiva(process.cwd(), endereco, {
|
|
160
|
+
motivo: motivo,
|
|
161
|
+
substituidaPor: argumento(argv, "substituida-por"),
|
|
162
|
+
});
|
|
163
|
+
console.log(`arquivado ${r.endereco} (${r.motivo})`);
|
|
164
|
+
console.log(" foi para o histórico, não foi apagada. Rode `dd-harness sync`.");
|
|
165
|
+
}
|
|
166
|
+
async function comandoBuscar(argv) {
|
|
167
|
+
const consulta = argv.filter((a) => !a.startsWith("--")).join(" ").trim();
|
|
168
|
+
if (!consulta)
|
|
169
|
+
throw new Error('uso: dd-harness buscar "<pergunta>"');
|
|
170
|
+
const limite = Number(argumento(argv, "limite")) || undefined;
|
|
171
|
+
const r = await busca(process.cwd(), consulta, limite);
|
|
172
|
+
if (!r.achados.length) {
|
|
173
|
+
console.log("nada encontrado.");
|
|
174
|
+
// Sem esta linha, "nada encontrado" vira "nao existe" — quando pode ser so a
|
|
175
|
+
// semantica desligada, e a memoria estar la com outras palavras.
|
|
176
|
+
if (!r.semantica) {
|
|
177
|
+
console.log(" (só busca textual: sem provedor de embedding no serviço)");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
console.log(r.semantica
|
|
182
|
+
? `${r.achados.length} resultado(s) — busca híbrida (${r.provedor}):`
|
|
183
|
+
: `${r.achados.length} resultado(s) — só textual, sem semântica:`);
|
|
184
|
+
for (const a of r.achados) {
|
|
185
|
+
console.log(` ${a.endereco} — ${a.titulo}`);
|
|
186
|
+
console.log(` ${a.resumo}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function comandoSync() {
|
|
190
|
+
const resultado = await sync(process.cwd());
|
|
191
|
+
if (resultado.tipo === "editado-a-mao") {
|
|
192
|
+
console.error([
|
|
193
|
+
"parei sem escrever nada: estes arquivos foram editados à mão.",
|
|
194
|
+
...resultado.arquivos.map((a) => ` ${a}`),
|
|
195
|
+
"",
|
|
196
|
+
"O disco é projeção do serviço, uma direção só. Duas saídas:",
|
|
197
|
+
"",
|
|
198
|
+
" 1. Leve a edição para o serviço — `dd-harness editar <arquivo>` para cada um",
|
|
199
|
+
" acima. É o caminho normal de corrigir memória, e destrava o sync.",
|
|
200
|
+
" 2. Descarte a edição local (git checkout / apague o arquivo) e sincronize.",
|
|
201
|
+
].join("\n"));
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (resultado.tipo === "sem-mudanca") {
|
|
206
|
+
console.log("nada mudou no serviço — disco já está em dia.");
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
for (const a of resultado.escritos)
|
|
210
|
+
console.log(`escrito ${a}`);
|
|
211
|
+
for (const a of resultado.removidos)
|
|
212
|
+
console.log(`removido ${a}`);
|
|
213
|
+
if (!resultado.escritos.length && !resultado.removidos.length) {
|
|
214
|
+
console.log("conteúdo novo do serviço, sem diferença em disco.");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
avisaSobreOPonteiro(resultado.ponteiro);
|
|
218
|
+
}
|
|
219
|
+
async function comandoCheck(argv) {
|
|
220
|
+
const r = await check(process.cwd(), argumento(argv, "commit"));
|
|
221
|
+
if (r.medidas === 0) {
|
|
222
|
+
console.log("nenhuma âncora para medir.");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
console.log(`medidas ${r.medidas} âncora(s).`);
|
|
226
|
+
if (r.base > 0) {
|
|
227
|
+
console.log(` ${r.base} medida(s) pela primeira vez — viraram linha de base.`);
|
|
228
|
+
}
|
|
229
|
+
// Ausente e alterado nao pesam igual: o alvo ter sumido e sinal forte, e some no meio
|
|
230
|
+
// do ruido se for anunciado do mesmo jeito que "mudou".
|
|
231
|
+
if (r.ausentes.length) {
|
|
232
|
+
console.log("");
|
|
233
|
+
console.log("ALVO AUSENTE — alguém apagou ou moveu o que uma memória guarda:");
|
|
234
|
+
for (const valor of r.ausentes)
|
|
235
|
+
console.log(` ${valor}`);
|
|
236
|
+
}
|
|
237
|
+
if (r.novas > 0) {
|
|
238
|
+
console.log("");
|
|
239
|
+
console.log(`${r.novas} deriva(s) nova(s) registrada(s) no serviço.`);
|
|
240
|
+
}
|
|
241
|
+
if (r.jaAbertas > 0) {
|
|
242
|
+
console.log(`${r.jaAbertas} já estava(m) aberta(s) — nada novo, só o carimbo.`);
|
|
243
|
+
}
|
|
244
|
+
// Reverter uma mudanca fecha a observacao que ela abriu. Anunciar isso importa: sem a
|
|
245
|
+
// linha, a fila encolhe sem explicacao e quem olha o `status` acha que perdeu algo.
|
|
246
|
+
if (r.fechadas > 0) {
|
|
247
|
+
console.log(`${r.fechadas} deriva(s) fechada(s): o alvo voltou a bater com a linha de base.`);
|
|
248
|
+
}
|
|
249
|
+
if (!r.ausentes.length && r.novas === 0 && r.jaAbertas === 0 && r.fechadas === 0) {
|
|
250
|
+
console.log("nenhuma deriva: o mundo ainda bate com o que as memórias dizem.");
|
|
251
|
+
}
|
|
252
|
+
// O cruzamento com o diff e outra coisa que deriva: e "voce acabou de mexer no que
|
|
253
|
+
// esta memoria guarda". Vale mesmo quando a memoria continua valendo — e o revisor
|
|
254
|
+
// que o modelo file-based dava de graca, e que centralizar tinha tirado.
|
|
255
|
+
if (r.tocadas.length) {
|
|
256
|
+
console.log("");
|
|
257
|
+
console.log("Este commit mexeu no que estas memórias guardam:");
|
|
258
|
+
for (const t of r.tocadas) {
|
|
259
|
+
console.log(` ${t.pasta}/${t.memoria} — ${t.titulo}`);
|
|
260
|
+
console.log(` âncora: ${t.ancora}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function comandoStatus() {
|
|
265
|
+
const r = await status(process.cwd());
|
|
266
|
+
// O acervo vem primeiro, e sempre: e a resposta para "o que ha no Brain deste projeto?",
|
|
267
|
+
// que era a pergunta sem comando. Sem ela, quem quer saber conta arquivo em disco — e
|
|
268
|
+
// acerta por acidente, porque memoria arquivada sai do disco e continua no indice.
|
|
269
|
+
const { ativas, arquivadas, porPasta } = r.acervo;
|
|
270
|
+
if (ativas === 0 && arquivadas === 0) {
|
|
271
|
+
console.log("dd-harness: o Brain deste projeto está vazio.");
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const detalhe = porPasta.map((p) => `${p.pasta} ${p.quantas}`).join(", ");
|
|
275
|
+
console.log(`dd-harness: ${ativas} memória(s) ativa(s)` +
|
|
276
|
+
(arquivadas ? ` e ${arquivadas} arquivada(s)` : "") +
|
|
277
|
+
(detalhe ? ` — ${detalhe}` : ""));
|
|
278
|
+
}
|
|
279
|
+
if (!r.comDeriva.length && !r.vencidas.length) {
|
|
280
|
+
console.log("Nada esperando julgamento.");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.log("");
|
|
284
|
+
if (r.comDeriva.length) {
|
|
285
|
+
const total = r.comDeriva.reduce((soma, m) => soma + m.abertas, 0);
|
|
286
|
+
console.log(`dd-harness: ${total} deriva(s) aberta(s), esperando julgamento:`);
|
|
287
|
+
for (const m of r.comDeriva) {
|
|
288
|
+
console.log(` ${m.pasta}/${m.memoria} — ${m.titulo} (${m.abertas})`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (r.vencidas.length) {
|
|
292
|
+
console.log("");
|
|
293
|
+
console.log("Memórias com revisão vencida:");
|
|
294
|
+
for (const m of r.vencidas)
|
|
295
|
+
console.log(` ${m.pasta}/${m.memoria} — ${m.titulo}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Vem ANTES do `init`, e nao depois: o `init` escreve o `.dd-harness.json` apontando para
|
|
300
|
+
* um projeto, e apontar para projeto que nao existe deixaria todo comando seguinte em 404.
|
|
301
|
+
*/
|
|
302
|
+
async function comandoProjeto(argv) {
|
|
303
|
+
const slug = argv[0];
|
|
304
|
+
const nome = argumento(argv, "nome");
|
|
305
|
+
if (!slug || slug.startsWith("-") || !nome) {
|
|
306
|
+
throw new Error('uso: dd-harness projeto <slug> --nome "<nome>" [--tenant <slug>] [--api <url>]');
|
|
307
|
+
}
|
|
308
|
+
const r = await criaProjeto(process.cwd(), slug, nome, {
|
|
309
|
+
tenant: argumento(argv, "tenant"),
|
|
310
|
+
api: argumento(argv, "api"),
|
|
311
|
+
});
|
|
312
|
+
console.log(r.jaExistia
|
|
313
|
+
? `projeto ${r.projeto} já existia — nada criado.`
|
|
314
|
+
: `criado projeto ${r.projeto}.`);
|
|
315
|
+
console.log(`Agora: dd-harness init --tenant <espaço> --projeto ${r.projeto}`);
|
|
316
|
+
}
|
|
317
|
+
async function comandoPasta(argv) {
|
|
318
|
+
const slug = argv[0];
|
|
319
|
+
const definicao = argumento(argv, "definicao");
|
|
320
|
+
if (!slug || slug.startsWith("-") || !definicao) {
|
|
321
|
+
throw new Error('uso: dd-harness pasta <slug> --definicao "o que entra e o que não entra"');
|
|
322
|
+
}
|
|
323
|
+
const r = await criaPasta(process.cwd(), slug, definicao);
|
|
324
|
+
console.log(r.jaExistia
|
|
325
|
+
? `pasta ${r.pasta} já existia — nada criado.`
|
|
326
|
+
: `criada pasta ${r.pasta}. Agora \`dd-harness gravar <arquivo.md>\` aceita \`pasta: ${r.pasta}\`.`);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* O agente escreve o arquivo — que e o que ele ja fazia no modelo file-based — e este
|
|
330
|
+
* comando o transforma em requisicao. O arquivo nao fica no repositorio: quem
|
|
331
|
+
* materializa e o `sync`, a partir do servico, para nao existir copia escrita a mao ao
|
|
332
|
+
* lado da copia gerada.
|
|
333
|
+
*/
|
|
334
|
+
async function comandoGravar(argv) {
|
|
335
|
+
const caminho = argv[0];
|
|
336
|
+
if (!caminho || caminho.startsWith("-")) {
|
|
337
|
+
throw new Error("uso: dd-harness gravar <arquivo.md>");
|
|
338
|
+
}
|
|
339
|
+
const r = await grava(process.cwd(), caminho);
|
|
340
|
+
console.log(`gravado ${r.endereco}`);
|
|
341
|
+
console.log(` ${r.ancoras} âncora(s), valendo para ${r.projetos} projeto(s).` +
|
|
342
|
+
" Rode `dd-harness sync` para materializar.");
|
|
343
|
+
}
|
|
344
|
+
async function principal() {
|
|
345
|
+
const [comando, ...resto] = process.argv.slice(2);
|
|
346
|
+
switch (comando) {
|
|
347
|
+
case "init":
|
|
348
|
+
return comandoInit(resto);
|
|
349
|
+
case "login":
|
|
350
|
+
return comandoLogin(resto);
|
|
351
|
+
case "projeto":
|
|
352
|
+
return comandoProjeto(resto);
|
|
353
|
+
case "pasta":
|
|
354
|
+
return comandoPasta(resto);
|
|
355
|
+
case "gravar":
|
|
356
|
+
return comandoGravar(resto);
|
|
357
|
+
case "editar":
|
|
358
|
+
return comandoEditar(resto);
|
|
359
|
+
case "arquivar":
|
|
360
|
+
return comandoArquivar(resto);
|
|
361
|
+
case "buscar":
|
|
362
|
+
return comandoBuscar(resto);
|
|
363
|
+
case "sync":
|
|
364
|
+
return comandoSync();
|
|
365
|
+
case "check":
|
|
366
|
+
return comandoCheck(resto);
|
|
367
|
+
case "status":
|
|
368
|
+
return comandoStatus();
|
|
369
|
+
case "--help":
|
|
370
|
+
case "-h":
|
|
371
|
+
case undefined:
|
|
372
|
+
console.log(AJUDA);
|
|
373
|
+
return;
|
|
374
|
+
default:
|
|
375
|
+
throw new Error(`comando desconhecido: ${comando}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
principal().catch((erro) => {
|
|
379
|
+
console.error(erro instanceof Error ? erro.message : String(erro));
|
|
380
|
+
process.exitCode = 1;
|
|
381
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CAMINHO_CONFIG } from "./config.js";
|
|
4
|
+
import { LINHA_DE_IMPORT } from "./materializa.js";
|
|
5
|
+
/**
|
|
6
|
+
* Prepara um repositorio para o dd-harness.
|
|
7
|
+
*
|
|
8
|
+
* Funciona nos dois casos, e o segundo e o que importa para a ambicao de adotar projeto
|
|
9
|
+
* que ja existe: se nao ha `CLAUDE.md`, cria um com a linha de import; se **ja ha**,
|
|
10
|
+
* acrescenta a linha ao que voce escreveu, sem tocar no resto. Adotar um projeto vira
|
|
11
|
+
* uma linha, e nao um ritual de mover arquivo.
|
|
12
|
+
*/
|
|
13
|
+
const CABECALHO = `# CLAUDE.md
|
|
14
|
+
|
|
15
|
+
Este arquivo é seu: escreva aqui o que for específico deste repositório.
|
|
16
|
+
|
|
17
|
+
A linha abaixo importa a política gerenciada pelo dd-harness. **Não a remova** — sem
|
|
18
|
+
ela a sessão abre sem protocolo, e nada avisa.
|
|
19
|
+
`;
|
|
20
|
+
/**
|
|
21
|
+
* O `.mcp.json` e sugerido, nunca escrito.
|
|
22
|
+
*
|
|
23
|
+
* Pelo mesmo motivo do `CLAUDE.md` da raiz: o arquivo e do repositorio, pode ja declarar
|
|
24
|
+
* outros servidores, e mesclar JSON alheio e onde falha silenciosa nasce — entrada errada
|
|
25
|
+
* nao da erro, a ferramenta so nao aparece. Quem cola sabe o que colou.
|
|
26
|
+
*/
|
|
27
|
+
export const SUGESTAO_MCP = `{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"dd-harness": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": ["-y", "dd-harness-mcp"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}`;
|
|
35
|
+
async function declaraMcp(raiz) {
|
|
36
|
+
try {
|
|
37
|
+
const cru = await readFile(join(raiz, ".mcp.json"), "utf8");
|
|
38
|
+
const lido = JSON.parse(cru);
|
|
39
|
+
return lido.mcpServers?.["dd-harness"] ? "ja-declarado" : "a-declarar";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Sem arquivo, ou JSON que nao interpreta: nos dois casos ha o que sugerir.
|
|
43
|
+
return "a-declarar";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function init(raiz, dados) {
|
|
47
|
+
const caminhoConfig = join(raiz, CAMINHO_CONFIG);
|
|
48
|
+
let config = "ja-existia";
|
|
49
|
+
try {
|
|
50
|
+
await readFile(caminhoConfig, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
const conteudo = {
|
|
54
|
+
...(dados.api ? { api: dados.api } : {}),
|
|
55
|
+
tenant: dados.tenant,
|
|
56
|
+
projeto: dados.projeto,
|
|
57
|
+
};
|
|
58
|
+
await writeFile(caminhoConfig, `${JSON.stringify(conteudo, null, 2)}\n`, "utf8");
|
|
59
|
+
config = "criada";
|
|
60
|
+
}
|
|
61
|
+
const caminhoClaude = join(raiz, "CLAUDE.md");
|
|
62
|
+
let claudeMd;
|
|
63
|
+
let atual = null;
|
|
64
|
+
try {
|
|
65
|
+
atual = await readFile(caminhoClaude, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
atual = null;
|
|
69
|
+
}
|
|
70
|
+
if (atual === null) {
|
|
71
|
+
await writeFile(caminhoClaude, `${CABECALHO}\n${LINHA_DE_IMPORT}\n`, "utf8");
|
|
72
|
+
claudeMd = "criado";
|
|
73
|
+
}
|
|
74
|
+
else if (atual.includes(LINHA_DE_IMPORT)) {
|
|
75
|
+
claudeMd = "ja-tinha-a-linha";
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Acrescenta no fim, sem reescrever nada do que ja estava la.
|
|
79
|
+
const separador = atual.endsWith("\n") ? "\n" : "\n\n";
|
|
80
|
+
await writeFile(caminhoClaude, `${atual}${separador}${LINHA_DE_IMPORT}\n`, "utf8");
|
|
81
|
+
claudeMd = "linha-acrescentada";
|
|
82
|
+
}
|
|
83
|
+
return { config, claudeMd, mcp: await declaraMcp(raiz) };
|
|
84
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Do payload do contrato para arquivos em disco.
|
|
3
|
+
*
|
|
4
|
+
* Funcao pura de proposito: recebe o Brain e devolve caminho -> conteudo, sem rede e sem
|
|
5
|
+
* `fs`. E a parte que precisa de teste — o formato tem que casar com o que o
|
|
6
|
+
* `validate_brain.cjs` do molde espera (frontmatter `name` igual ao arquivo, `pasta`
|
|
7
|
+
* igual a pasta que o contem, e uma linha de indice por memoria).
|
|
8
|
+
*/
|
|
9
|
+
const AVISO = "<!-- GERADO por `dd-harness sync`. Edite no serviço, não aqui: a próxima sincronização recusa arquivo alterado à mão. -->";
|
|
10
|
+
/** Uma linha por âncora, para o leitor humano saber de que a memória depende. */
|
|
11
|
+
function secaoDeAncoras(ancoras) {
|
|
12
|
+
if (ancoras.length === 0)
|
|
13
|
+
return "";
|
|
14
|
+
const linhas = ancoras.map((a) => `- \`${a.valor}\``).join("\n");
|
|
15
|
+
return `\n## Âncoras\n\nDe que esta memória depende — mudança aqui é o que dispara aviso de deriva.\n\n${linhas}\n`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Os três filtros existem como coluna no serviço, e é isso que os torna auditáveis.
|
|
19
|
+
* Em disco eles viram seção, porque o frontmatter do molde não tem campo para texto
|
|
20
|
+
* longo e YAML multilinha é armadilha de escape.
|
|
21
|
+
*/
|
|
22
|
+
function secaoDeFiltros(m) {
|
|
23
|
+
return [
|
|
24
|
+
"\n## Os três filtros\n",
|
|
25
|
+
`**Dano:** ${m.dano}\n`,
|
|
26
|
+
`**Invisibilidade:** ${m.invisibilidade}\n`,
|
|
27
|
+
`**Externalidade:** ${m.externalidade}\n`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
export function arquivoDaMemoria(m) {
|
|
31
|
+
// `titulo` vai no frontmatter porque `gravar` e `editar` o exigem la: sem ele o arquivo
|
|
32
|
+
// materializado nao volta pelo `editar`, e o ciclo "sincroniza, corrige, manda de volta"
|
|
33
|
+
// — que e como o agente cura memoria — para com "frontmatter sem `titulo`". O titulo
|
|
34
|
+
// tambem aparece no indice, mas indice nao e o que se edita.
|
|
35
|
+
const frontmatter = [
|
|
36
|
+
"---",
|
|
37
|
+
`name: ${m.slug}`,
|
|
38
|
+
`titulo: ${m.titulo.replace(/\n/g, " ")}`,
|
|
39
|
+
`description: ${m.resumo.replace(/\n/g, " ")}`,
|
|
40
|
+
`pasta: ${m.pasta}`,
|
|
41
|
+
...(m.revisar_ate ? [`revisar-ate: ${m.revisar_ate.slice(0, 10)}`] : []),
|
|
42
|
+
"---",
|
|
43
|
+
].join("\n");
|
|
44
|
+
return [
|
|
45
|
+
frontmatter,
|
|
46
|
+
"",
|
|
47
|
+
AVISO,
|
|
48
|
+
"",
|
|
49
|
+
m.corpo.trimEnd(),
|
|
50
|
+
secaoDeFiltros(m),
|
|
51
|
+
secaoDeAncoras(m.ancoras),
|
|
52
|
+
]
|
|
53
|
+
.join("\n")
|
|
54
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
55
|
+
.trimEnd()
|
|
56
|
+
.concat("\n");
|
|
57
|
+
}
|
|
58
|
+
export function arquivoDoIndice(brain) {
|
|
59
|
+
const ativas = brain.memorias.filter((m) => m.status === "ativa");
|
|
60
|
+
const historico = brain.memorias.filter((m) => m.status === "historico");
|
|
61
|
+
const linha = (m) => `- [${m.titulo}](${m.pasta}/${m.slug}.md) — ${m.resumo.replace(/\n/g, " ")}`;
|
|
62
|
+
// O indice e montado pelas pastas, mas quem manda e a memoria: pasta que so aparece no
|
|
63
|
+
// `pasta:` de uma memoria tambem vira secao. Sem isso a memoria fica em disco e fora do
|
|
64
|
+
// indice — arquivo presente que nenhum agente encontra, que e a mesma falha silenciosa
|
|
65
|
+
// do import quebrado.
|
|
66
|
+
const definicoes = new Map(brain.pastas.map((p) => [p.slug, p.definicao]));
|
|
67
|
+
const slugs = [...new Set([...definicoes.keys(), ...ativas.map((m) => m.pasta)])].sort();
|
|
68
|
+
const secoes = slugs
|
|
69
|
+
.map((slug) => {
|
|
70
|
+
const memorias = ativas.filter((m) => m.pasta === slug);
|
|
71
|
+
const definicao = definicoes.get(slug);
|
|
72
|
+
return [
|
|
73
|
+
`## ${slug}`,
|
|
74
|
+
`<!-- ${definicao ?? "(definição não veio do serviço)"} -->`,
|
|
75
|
+
...(memorias.length ? memorias.map(linha) : ["<!-- (vazia) -->"]),
|
|
76
|
+
].join("\n");
|
|
77
|
+
})
|
|
78
|
+
.join("\n\n");
|
|
79
|
+
const secaoHistorico = [
|
|
80
|
+
"## Histórico",
|
|
81
|
+
"<!-- Memórias substituídas por uma decisão mais nova. -->",
|
|
82
|
+
...(historico.length ? historico.map(linha) : []),
|
|
83
|
+
].join("\n");
|
|
84
|
+
return [
|
|
85
|
+
"# 🧠 Brain — Índice",
|
|
86
|
+
"",
|
|
87
|
+
AVISO,
|
|
88
|
+
"",
|
|
89
|
+
`Projeto **${brain.projeto.nome}** (\`${brain.projeto.slug}\`), espaço **${brain.tenant.nome}**.`,
|
|
90
|
+
"Uma linha por memória. As regras de gravar, encaixar e curar estão no `CLAUDE.md`.",
|
|
91
|
+
"",
|
|
92
|
+
"---",
|
|
93
|
+
"",
|
|
94
|
+
secoes,
|
|
95
|
+
"",
|
|
96
|
+
secaoHistorico,
|
|
97
|
+
"",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
/** Tudo o que o servico gera vive aqui — e nada fora daqui e escrito pelo `sync`. */
|
|
101
|
+
export const PASTA = "dd-harness";
|
|
102
|
+
/** O que a raiz precisa conter para a politica chegar a sessao. */
|
|
103
|
+
export const LINHA_DE_IMPORT = `@${PASTA}/politica.md`;
|
|
104
|
+
/**
|
|
105
|
+
* Caminho relativo (POSIX) -> conteúdo. As chaves são o que o manifesto guarda.
|
|
106
|
+
*
|
|
107
|
+
* Tudo dentro de `dd-harness/`, inclusive a política. Na raiz fica só o `CLAUDE.md`, que
|
|
108
|
+
* é **seu**: o `sync` não o escreve, apenas confere que ele importa a política. É o que
|
|
109
|
+
* deixa conviverem a parte gerenciada e o que aquele repositório tem de próprio — e o
|
|
110
|
+
* que faz adotar um projeto existente ser uma linha, não um ritual.
|
|
111
|
+
*
|
|
112
|
+
* Artefato vazio ou ausente não entra no mapa; como o manifesto remove o que saiu do
|
|
113
|
+
* conjunto, esvaziar no serviço apaga o arquivo no próximo `sync`.
|
|
114
|
+
*/
|
|
115
|
+
export function materializa(brain, pasta = PASTA) {
|
|
116
|
+
const arquivos = new Map();
|
|
117
|
+
const artefatos = [
|
|
118
|
+
[`${pasta}/politica.md`, brain.politica],
|
|
119
|
+
[`${pasta}/BRIEFING.md`, brain.briefing],
|
|
120
|
+
];
|
|
121
|
+
for (const [caminho, conteudo] of artefatos) {
|
|
122
|
+
// Termina com quebra de linha: arquivo de texto sem ela irrita todo diff.
|
|
123
|
+
if (conteudo && conteudo.trim()) {
|
|
124
|
+
arquivos.set(caminho, conteudo.replace(/\n*$/, "\n"));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
arquivos.set(`${pasta}/brain/MEMORY.md`, arquivoDoIndice(brain));
|
|
128
|
+
for (const m of brain.memorias) {
|
|
129
|
+
arquivos.set(`${pasta}/brain/${m.pasta}/${m.slug}.md`, arquivoDaMemoria(m));
|
|
130
|
+
}
|
|
131
|
+
return arquivos;
|
|
132
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Medir o alvo de uma ancora. E a metade da deriva que so o cliente pode fazer: o
|
|
6
|
+
* servico nunca ve o repositorio.
|
|
7
|
+
*
|
|
8
|
+
* Devolve `null` quando o alvo nao existe — e o servico que decide se isso e deriva.
|
|
9
|
+
*/
|
|
10
|
+
/** Arquivo: hash do conteudo. E o que responde "mudou?" sem ambiguidade. */
|
|
11
|
+
async function hashDeArquivo(caminho) {
|
|
12
|
+
return createHash("sha256").update(await readFile(caminho)).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Diretorio: hash da LISTA de arquivos, nao do conteudo deles.
|
|
16
|
+
*
|
|
17
|
+
* Uma ancora em `supabase/migrations` quer dizer "o conjunto de migrations importa" —
|
|
18
|
+
* migration nova e o sinal. Se o hash levasse o conteudo junto, qualquer ajuste de
|
|
19
|
+
* comentario dentro de qualquer arquivo acusaria deriva, e a fila viraria ruido.
|
|
20
|
+
*/
|
|
21
|
+
async function hashDeDiretorio(caminho) {
|
|
22
|
+
const nomes = [];
|
|
23
|
+
async function anda(dir, prefixo) {
|
|
24
|
+
for (const entrada of await readdir(dir, { withFileTypes: true })) {
|
|
25
|
+
const rel = prefixo ? posix.join(prefixo, entrada.name) : entrada.name;
|
|
26
|
+
if (entrada.isDirectory())
|
|
27
|
+
await anda(join(dir, entrada.name), rel);
|
|
28
|
+
else
|
|
29
|
+
nomes.push(rel);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await anda(caminho, "");
|
|
33
|
+
return createHash("sha256").update(nomes.sort().join("\n")).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Trecho: `caminho#alvo` — hash das LINHAS que contem `alvo`, nao do arquivo.
|
|
37
|
+
*
|
|
38
|
+
* Existe porque ancora de arquivo mede grosso demais. Medido: renomear a variavel de um
|
|
39
|
+
* laco disparou as 5 memorias ancoradas naquele arquivo, nenhuma delas desatualizada. Cada
|
|
40
|
+
* uma falava de um trecho diferente, e o hash do arquivo nao distingue.
|
|
41
|
+
*
|
|
42
|
+
* `null` quando o alvo nao aparece mais: e o mesmo sinal de "arquivo ausente" — o trecho que
|
|
43
|
+
* a memoria descreve deixou de existir, e o servico decide o que isso significa. Trecho que
|
|
44
|
+
* some por rename e deriva legitima, nao falso positivo: a memoria aponta para algo que
|
|
45
|
+
* nao esta mais la.
|
|
46
|
+
*
|
|
47
|
+
* Comparacao literal, sem regex: o alvo vem de quem escreveu a memoria, e regex daria
|
|
48
|
+
* poder de travar o `check` (catastrophic backtracking) a quem so queria apontar uma linha.
|
|
49
|
+
*/
|
|
50
|
+
async function hashDeTrecho(caminho, alvo) {
|
|
51
|
+
const conteudo = await readFile(caminho, "utf8");
|
|
52
|
+
const casam = conteudo
|
|
53
|
+
.split(/\r?\n/)
|
|
54
|
+
.filter((linha) => linha.includes(alvo))
|
|
55
|
+
.map((linha) => linha.trim());
|
|
56
|
+
if (casam.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
return createHash("sha256").update(casam.join("\n"), "utf8").digest("hex");
|
|
59
|
+
}
|
|
60
|
+
export async function mede(raiz, valor) {
|
|
61
|
+
// O `#` separa alvo de caminho. O CHECK do banco garante um so, e nenhum lado vazio.
|
|
62
|
+
const corte = valor.indexOf("#");
|
|
63
|
+
if (corte !== -1) {
|
|
64
|
+
const caminho = join(raiz, valor.slice(0, corte));
|
|
65
|
+
const alvo = valor.slice(corte + 1);
|
|
66
|
+
try {
|
|
67
|
+
const info = await stat(caminho);
|
|
68
|
+
// Trecho de diretorio nao existe: o alvo e texto dentro de um arquivo.
|
|
69
|
+
if (info.isDirectory())
|
|
70
|
+
return null;
|
|
71
|
+
return await hashDeTrecho(caminho, alvo);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const caminho = join(raiz, valor);
|
|
78
|
+
try {
|
|
79
|
+
const info = await stat(caminho);
|
|
80
|
+
return info.isDirectory()
|
|
81
|
+
? await hashDeDiretorio(caminho)
|
|
82
|
+
: await hashDeArquivo(caminho);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|