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.
@@ -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
+ }