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.
@@ -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
- * `fetch` com nova tentativa para falha transitoria.
25
- *
26
- * Medido em uso real: um `ECONNRESET` de conexao ociosa derrubou um `arquivar` que
27
- * funcionou na segunda tentativa manual. Sem retry, falha de rede de um segundo vira erro
28
- * final — e para um agente isso e a diferenca entre seguir sozinho e parar para pedir
29
- * ajuda. Com varios agentes concorrendo, transitorio deixa de ser raro.
30
- *
31
- * O que NAO e repetido, e o cuidado que importa:
32
- *
33
- * - Erro de aplicacao (4xx). Token invalido ou memoria fora dos filtros nao melhora na
34
- * segunda tentativa; repetir so atrasa a mensagem que o agente precisa ler.
35
- * - 5xx em requisicao que ESCREVE. `POST /memorias` pode ter gravado antes de a resposta
36
- * se perder, e repetir criaria duas. Perder a resposta de uma escrita que funcionou e
37
- * ruim; gravar duas vezes e pior.
38
- *
39
- * Entao: repete falha de REDE (o `fetch` nem chegou a receber resposta) sempre, e 5xx
40
- * apenas quando o metodo e seguro de repetir.
41
- */
42
- const IDEMPOTENTES = new Set(["GET", "HEAD", "PUT", "DELETE"]);
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 = IDEMPOTENTES.has(metodo);
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 resposta = await fetch(url, init);
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
- // Aqui o `fetch` falhou antes de qualquer resposta: DNS, conexao recusada,
59
- // ECONNRESET. Nada foi processado do outro lado, entao repetir e seguro mesmo
60
- // para POST.
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
+ }
@@ -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
- return (await resposta.json());
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
  }
@@ -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, ".claude", "dd-harness-ancoras.json");
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
- if (memoria.ancoras.length === 0)
55
+ const existente = await leCache(raiz);
56
+ if (!existente && memoria.ancoras.length === 0)
44
57
  return;
45
- const atual = (await leCache(raiz)) ?? { gravado_em: "", memorias: [] };
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 writeFile(caminho, `${JSON.stringify(cache)}\n`, "utf8");
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 writeFile(caminho, `${JSON.stringify(cache)}\n`, "utf8");
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
- return JSON.parse(await readFile(caminhoDoCache(raiz), "utf8"));
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 caminho = join(raiz, ".claude", `dd-harness-avisos-${sessao.replace(/[^\w-]/g, "")}.json`);
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
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
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
- const arquivoDeCredenciais = () => join(homedir(), ".dd-harness", "credentials.json");
34
- const arquivoDeConfigDaMaquina = () => join(homedir(), ".dd-harness", "config.json");
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(join(homedir(), ".dd-harness"), { recursive: true });
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(join(homedir(), ".dd-harness"), { recursive: true });
97
+ await mkdir(diretorioDoUsuario(), { recursive: true });
69
98
  let mapa = {};
70
99
  try {
71
100
  mapa = JSON.parse(await readFile(caminho, "utf8"));
@@ -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
- const lido = (await resposta.json());
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
- return (await resposta.json());
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
- return (await resposta.json());
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
- Depois, \`ler_roadmap\`: se houver uma fase **Agora**, é dela que saem os passos
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
+ }
@@ -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
- const { endereco } = (await resposta.json());
192
- // O cinto passa a enxergar esta memoria AGORA, nao na proxima sessao. Sem isto, a
193
- // memoria recem-gravada nao protegia nada ate a sessao seguinte medido na rodada 006,
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,