dd-harness 0.31.0 → 0.32.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/README.md CHANGED
@@ -1,64 +1,103 @@
1
- # dd-harness
2
-
3
- Materializa a **política**, o **briefing** e o **Brain** do
4
- [dd-harness](https://dd-harness.vercel.app) dentro do seu repositório, para que a sessão
5
- do agente de código abra sabendo as regras e as decisões do projeto.
6
-
7
- Sem dependência: `fetch`, `crypto` e `fs` são do Node. Um CLI que vive pinado em
8
- repositório alheio precisa envelhecer bem, e cada dependência é uma chance de não
9
- envelhecer.
10
-
11
- ## Instalação
12
-
13
- ```sh
14
- npm install -g dd-harness
15
- ```
16
-
17
- Ou sem instalar nada:
18
-
19
- ```sh
20
- npx dd-harness sync
21
- ```
22
-
23
- Requer Node 20 ou mais novo.
24
-
25
- ## Uso
26
-
27
- ```sh
28
- dd-harness init --tenant <espaço> --projeto <projeto> # prepara o repositório
29
- dd-harness login --token <token> # credencial desta máquina
30
- dd-harness sync # escreve os artefatos
31
- dd-harness check [--commit <sha>] # mede âncoras e reporta deriva
32
- dd-harness status # só lê: o que espera julgamento
33
- ```
34
-
35
- O token pessoal nasce na tela `/tokens` do serviço. Ele é guardado em
36
- `~/.dd-harness/credentials.json` (modo `0600`, por origem de API) **fora** do
37
- repositório, para não viajar num commit.
38
-
39
- ## O que aparece no seu repositório
40
-
41
- ```
42
- .dd-harness.json configuração (tenant, projeto, api) escrita à mão
43
- CLAUDE.md SEU arquivo; o sync nunca o reescreve
44
- dd-harness/ tudo o que é gerado
45
- politica.md
46
- BRIEFING.md
47
- brain/
48
- manifest.json
49
- ```
50
-
51
- Na raiz fica apenas o **seu** `CLAUDE.md`, que importa a política com a linha
52
- `@dd-harness/politica.md`. Isso deixa conviverem a parte gerenciada e o que o
53
- repositório tem de próprio adotar um projeto existente é acrescentar uma linha, não
54
- sobrescrever nada.
55
-
56
- O `sync` confere esse ponteiro a cada execução, inclusive quando o serviço não mudou:
57
- import quebrado ou linha ausente faz a sessão abrir **sem política e sem avisar**. Isso
58
- foi medido, não suposto.
59
-
60
- ## Deriva
61
-
62
- `dd-harness check` mede as âncoras das memórias contra o estado real do repositório e
63
- reporta o que saiu do lugar. Com `--commit <sha>` ele também cruza as âncoras com o diff
64
- daquele commit — a memória volta ao code review. Nada bloqueia: avisa.
1
+ # dd-harness
2
+
3
+ Política e briefing carregados do serviço; memória consultada por MCP ou CLI.
4
+ A fonte dos artefatos continua no serviço. O cliente mantém configuração, ponteiros
5
+ para skills e estado descartável fora do repositório.
6
+
7
+ Requer Node >=20.3.0. CLI sem dependências de runtime.
8
+
9
+ ## Instalação e configuração
10
+
11
+ ```sh
12
+ npm install -g dd-harness@latest
13
+ npx -y dd-harness-mcp@latest --help
14
+ dd-harness start --host todos
15
+ ```
16
+
17
+ Para um projeto já criado no serviço:
18
+
19
+ ```sh
20
+ dd-harness init --tenant meu-espaco --projeto meu-projeto
21
+ dd-harness login --token <token>
22
+ dd-harness integrar --host claude,codex,antigravity
23
+ dd-harness diagnostico --mcp
24
+ ```
25
+
26
+ `start` usa Claude por padrão. `--host` aceita `claude`, `codex`,
27
+ `antigravity`, uma lista separada por vírgulas ou `todos`.
28
+ `integrar` instala nos três por padrão e preserva configurações alheias.
29
+ O token nasce em /tokens e fica em ~/.dd-harness/credentials.json, por origem de API.
30
+ Para serviço local, use `init --api http://localhost:3000` antes do login.
31
+
32
+ ## Hosts
33
+
34
+ | Host | MCP | Boot e guarda | Skills |
35
+ |---|---|---|---|
36
+ | Claude Code | .mcp.json | .claude/settings.json | .claude/skills |
37
+ | Codex | .codex/config.toml | .codex/hooks.json | .agents/skills |
38
+ | Antigravity | .agents/mcp_config.json | .agents/hooks.json | .agents/skills |
39
+
40
+ Codex exige revisão/confiança dos hooks em /hooks. Instalação não comprova ativação.
41
+ Antigravity usa PreInvocation e mensagem efêmera: política e briefing são consultados
42
+ antes de cada inferência. Sua guarda usa `ask` para respeitar concessões já existentes;
43
+ ela pode acrescentar confirmações. Não amplia permissões automaticamente.
44
+
45
+ Sem boot válido, a guarda recusa ferramentas cobertas, inclusive shell. Leitura conhecida
46
+ e onboarding controlado continuam possíveis. Os hooks precisam estar ativos: não são
47
+ sandbox nem controlam ferramentas que o aplicativo não encaminha a eles.
48
+ Após boot válido, shell não tem análise de diff antecipada; avisos por âncora cobrem
49
+ edições estruturadas, patches, remoções e renomeações.
50
+
51
+ Configurações MCP novas levam DD_HARNESS_ROOT explícito. Ao copiar/mover um checkout,
52
+ confira essa raiz com diagnostico. O MCP recusa divergência entre a raiz explícita e
53
+ o projeto identificado no diretório de execução. Corrija a configuração e reinicie o host.
54
+
55
+ ## Uso diário
56
+
57
+ ```sh
58
+ dd-harness politica
59
+ dd-harness buscar "contrato de integração"
60
+ dd-harness ler regras/contrato
61
+ dd-harness gravar memoria.md
62
+ dd-harness editar memoria.md
63
+ dd-harness arquivar regras/contrato --motivo obsoleta
64
+ dd-harness status
65
+ dd-harness skills
66
+ dd-harness roadmap
67
+ dd-harness changelog
68
+ dd-harness --help
69
+ ```
70
+
71
+ `check` mede e grava observações de deriva; `status` só consulta.
72
+ O antigo comando `sync` não faz parte do CLI atual.
73
+ `politica --hook` e `cinto` permanecem como compatibilidade legada;
74
+ use `integrar` para instalar `sessao` e `guarda`.
75
+
76
+ ## Estado e conflitos
77
+
78
+ - Política/briefing não são materializados. Âncoras/resumos, sessão e manifestos ficam
79
+ em ~/.dd-harness/repos, isolados por caminho/projeto/credencial quando aplicável.
80
+ - DD_HARNESS_HOME permite isolamento explícito em testes. Nunca aponte testes ao estado pessoal.
81
+ - Uma sessão validada expira em quatro horas. Boot, retomada e compactação revalidam.
82
+ Alterações locais de política/briefing via MCP invalidam a sessão; reabra depois de editar.
83
+ Alterações remotas feitas por outro cliente são percebidas no próximo boot/revalidação.
84
+ - Curadoria invalida as âncoras do checkout atual. A próxima edição as consulta novamente.
85
+ - Só ponteiros registrados e intactos são atualizados/removidos. Skills manuais, editadas
86
+ ou redirecionadas por links são preservadas e aparecem como conflitos.
87
+ - Skills marcadas só por comando não são instaladas para descoberta automática em
88
+ .agents; use listar_skills/ler_skill após pedido explícito. Metadados próprios do
89
+ Claude não são prometidos como portáveis.
90
+ - Se há fila e worker local configurado, o boot solicita um lote. O log fica em
91
+ ~/.dd-harness/worker.log; solicitar não significa que a indexação concluiu.
92
+
93
+ ## Escritas e commits
94
+
95
+ Só GET/HEAD têm retry automático (até três tentativas, timeout por tentativa).
96
+ POST/PATCH/PUT/DELETE não são repetidos. Falha de conexão, 5xx ou resposta incompleta
97
+ em escrita exige consultar o estado antes de tentar novamente.
98
+
99
+ A política entregue exige `[IDENTIFICAÇÃO] - tipo: descrição` nos commits de agentes:
100
+ `[CODEX] - feat: ...`, `[CLAUDE] - fix: ...`, `[GEMINI] - docs: ...`.
101
+ Identifique quem realmente commitou, inclusive ao usar outro editor. A regra não altera
102
+ Git author nem concede autorização de commit/push. É regra de trabalho do agente,
103
+ não inferência automática de identidade nem hook que renomeia commits humanos.
package/dist/api.d.ts CHANGED
@@ -19,3 +19,5 @@ export declare function credencial(raiz: string): Promise<Credencial>;
19
19
  export declare function recusa(resposta: Response): Promise<never>;
20
20
  export declare function cabecalhos(token: string, comCorpo?: boolean): Record<string, string>;
21
21
  export declare function pede(url: string | URL, init?: RequestInit, tentativas?: number): Promise<Response>;
22
+ /** A conexão também pode cair depois dos headers, enquanto o corpo é consumido. */
23
+ export declare function jsonDaEscrita(resposta: Response): Promise<unknown>;
package/dist/api.js CHANGED
@@ -20,44 +20,39 @@ export function cabecalhos(token, comCorpo = false) {
20
20
  ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
21
21
  : { Authorization: `Bearer ${token}` };
22
22
  }
23
- /**
24
- * `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
+ }
package/dist/artefato.js CHANGED
@@ -1,4 +1,5 @@
1
- import { cabecalhos, credencial, pede, recusa } from "./api.js";
1
+ import { jsonDaEscrita, cabecalhos, credencial, pede, recusa } from "./api.js";
2
+ import { invalidaContexto } from "./estado-local.js";
2
3
  /**
3
4
  * O `GET` devolve o payload inteiro (politica, briefing, pastas e memorias); aqui so o
4
5
  * artefato pedido interessa.
@@ -38,5 +39,8 @@ export async function escreveArtefato(raiz, tipo, conteudo) {
38
39
  });
39
40
  if (!resposta.ok)
40
41
  await recusa(resposta);
41
- 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
  }
package/dist/cinto.d.ts CHANGED
@@ -21,12 +21,14 @@ import type { Brain, Memoria } from "./brain.js";
21
21
  */
22
22
  /** Onde o cache vive. Fora do repositorio: nada do dd-harness volta a morar em disco versionado. */
23
23
  export declare function caminhoDoCache(raiz: string): string;
24
+ export declare function invalidaCache(raiz: string): Promise<void>;
24
25
  /**
25
26
  * So o que o cruzamento precisa. O payload inteiro tem briefing, politica e o corpo de
26
27
  * toda memoria — carregar isso a cada `Edit` seria pagar leitura de disco grande para,
27
28
  * quase sempre, nao casar com nada.
28
29
  */
29
30
  export type CacheDeAncoras = {
31
+ projeto?: string;
30
32
  gravado_em: string;
31
33
  memorias: Pick<Memoria, "pasta" | "slug" | "titulo" | "resumo" | "status" | "ancoras" | "escopo">[];
32
34
  };
package/dist/cinto.js CHANGED
@@ -1,4 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { estadoDoRepo, escreveAtomico } from "./estado-local.js";
4
+ import { leConfigDoRepo, leToken } from "./config.js";
2
5
  import { dirname, join, relative, resolve, sep } from "node:path";
3
6
  import { memoriasNoConteudo } from "./diff.js";
4
7
  /**
@@ -23,7 +26,16 @@ import { memoriasNoConteudo } from "./diff.js";
23
26
  */
24
27
  /** Onde o cache vive. Fora do repositorio: nada do dd-harness volta a morar em disco versionado. */
25
28
  export function caminhoDoCache(raiz) {
26
- return join(raiz, ".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
  }
package/dist/config.d.ts CHANGED
@@ -17,6 +17,10 @@ export type ConfigDoRepo = {
17
17
  export declare const CAMINHO_CONFIG = ".dd-harness.json";
18
18
  export declare const CAMINHO_MANIFESTO = "dd-harness/manifest.json";
19
19
  export declare function leConfigDoRepo(raiz: string): Promise<ConfigDoRepo>;
20
+ /** Sobe apenas até a raiz git; não herda outro projeto acima de um worktree. */
21
+ export declare function achaRaiz(inicio?: string): Promise<string>;
22
+ /** Uma configuração copiada de outro checkout nunca pode direcionar operações silenciosamente. */
23
+ export declare function raizDoMcp(): Promise<string>;
20
24
  export type ConfigDaMaquina = {
21
25
  /**
22
26
  * Onde o monorepo `dd-harness_new` esta clonado nesta maquina. O worker de embeddings
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { 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"));
package/dist/curar.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { invalidaCache } from "./cinto.js";
1
2
  import { readFile } from "node:fs/promises";
2
- import { cabecalhos, credencial, pede, recusa } from "./api.js";
3
+ import { jsonDaEscrita, cabecalhos, credencial, pede, recusa } from "./api.js";
3
4
  import { interpreta } from "./gravar.js";
4
5
  /**
5
6
  * A memoria inteira, no markdown que `gravar` e `editar` consomem.
@@ -73,6 +74,7 @@ export async function edita(raiz, caminho) {
73
74
  });
74
75
  if (!resposta.ok)
75
76
  await recusa(resposta);
77
+ await invalidaCache(raiz);
76
78
  return { endereco, ancoras: memoria.ancoras.length };
77
79
  }
78
80
  export async function arquiva(raiz, endereco, opcoes) {
@@ -89,7 +91,8 @@ export async function arquiva(raiz, endereco, opcoes) {
89
91
  });
90
92
  if (!resposta.ok)
91
93
  await recusa(resposta);
92
- 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,5 @@
1
+ export declare function handshakeMcp(raiz: string, comando?: string, args?: string[]): Promise<{
2
+ versao: string;
3
+ ferramentas: string[];
4
+ }>;
5
+ export declare function diagnostico(inicio: string, testarMcp?: boolean): Promise<string[]>;
@@ -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,15 @@
1
+ export type Host = "claude" | "codex" | "antigravity";
2
+ export type Chamada = {
3
+ raiz: string;
4
+ sessao: string;
5
+ ferramenta: string;
6
+ args: Record<string, unknown>;
7
+ };
8
+ export type Edicao = {
9
+ arquivo: string;
10
+ antes?: string;
11
+ depois?: string;
12
+ origem?: string;
13
+ };
14
+ export declare function normalizaEntrada(host: Host, entrada: Record<string, unknown>): Chamada;
15
+ export declare function edicoesDaChamada(c: Chamada): Edicao[];