dd-harness 0.1.3 → 0.2.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/LICENSE CHANGED
@@ -1,11 +1,11 @@
1
- Copyright (c) 2026 Diego Dias
2
-
3
- Todos os direitos reservados.
4
-
5
- Este software é publicado no registro npm apenas para distribuição ao seu autor e a
6
- quem ele autorizar expressamente. Nenhuma permissão de uso, cópia, modificação,
7
- distribuição ou criação de obras derivadas é concedida por esta publicação.
8
-
9
- All rights reserved. This software is published to the npm registry for distribution
10
- to its author and to those he expressly authorizes. No permission to use, copy,
11
- modify, distribute, or create derivative works is granted by this publication.
1
+ Copyright (c) 2026 Diego Dias
2
+
3
+ Todos os direitos reservados.
4
+
5
+ Este software é publicado no registro npm apenas para distribuição ao seu autor e a
6
+ quem ele autorizar expressamente. Nenhuma permissão de uso, cópia, modificação,
7
+ distribuição ou criação de obras derivadas é concedida por esta publicação.
8
+
9
+ All rights reserved. This software is published to the npm registry for distribution
10
+ to its author and to those he expressly authorizes. No permission to use, copy,
11
+ modify, distribute, or create derivative works is granted by this publication.
package/README.md CHANGED
@@ -1,64 +1,64 @@
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 já 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
+ 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 já 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.
package/dist/api.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { type ConfigDoRepo } from "./config.js";
2
+ /**
3
+ * A fronteira com o servico: ler credencial e falar HTTP.
4
+ *
5
+ * Cada comando reimplementava as mesmas tres linhas — le config, le token, monta `fetch`
6
+ * com `Authorization` — e cada copia era uma chance de divergir na mensagem de erro.
7
+ * Aqui elas existem uma vez, e o servidor MCP consome as mesmas funcoes que o CLI: as
8
+ * duas portas falam com a API pelo mesmo caminho, entao corrigir uma corrige a outra.
9
+ */
10
+ export type Credencial = {
11
+ config: ConfigDoRepo;
12
+ token: string;
13
+ };
14
+ export declare function credencial(raiz: string): Promise<Credencial>;
15
+ /**
16
+ * O `erro` que a API devolve no corpo e mais util que o status, e e o que o agente le.
17
+ * Sem corpo interpretavel, o status ao menos diz que houve resposta.
18
+ */
19
+ export declare function recusa(resposta: Response): Promise<never>;
20
+ export declare function cabecalhos(token: string, comCorpo?: boolean): Record<string, string>;
21
+ export declare function pede(url: string | URL, init?: RequestInit, tentativas?: number): Promise<Response>;
package/dist/api.js ADDED
@@ -0,0 +1,69 @@
1
+ import { leConfigDoRepo, leToken } from "./config.js";
2
+ export async function credencial(raiz) {
3
+ const config = await leConfigDoRepo(raiz);
4
+ const token = await leToken(config.api);
5
+ if (!token) {
6
+ throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
7
+ }
8
+ return { config, token };
9
+ }
10
+ /**
11
+ * O `erro` que a API devolve no corpo e mais util que o status, e e o que o agente le.
12
+ * Sem corpo interpretavel, o status ao menos diz que houve resposta.
13
+ */
14
+ export async function recusa(resposta) {
15
+ const { erro } = (await resposta.json().catch(() => ({})));
16
+ throw new Error(erro ?? `a API respondeu ${resposta.status}.`);
17
+ }
18
+ export function cabecalhos(token, comCorpo = false) {
19
+ return comCorpo
20
+ ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
21
+ : { Authorization: `Bearer ${token}` };
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"]);
43
+ const espera = (ms) => new Promise((r) => setTimeout(r, ms));
44
+ export async function pede(url, init = {}, tentativas = 3) {
45
+ const metodo = (init.method ?? "GET").toUpperCase();
46
+ const podeRepetirErroDoServidor = IDEMPOTENTES.has(metodo);
47
+ let ultimoErro;
48
+ for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
49
+ try {
50
+ const resposta = await fetch(url, init);
51
+ if (resposta.status >= 500 && podeRepetirErroDoServidor && tentativa < tentativas) {
52
+ await espera(tentativa * 400);
53
+ continue;
54
+ }
55
+ return resposta;
56
+ }
57
+ 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.
61
+ ultimoErro = erro;
62
+ if (tentativa === tentativas)
63
+ break;
64
+ await espera(tentativa * 400);
65
+ }
66
+ }
67
+ const detalhe = ultimoErro instanceof Error ? ultimoErro.message : String(ultimoErro);
68
+ throw new Error(`não consegui falar com o serviço depois de ${tentativas} tentativas: ${detalhe}`);
69
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `dd-harness buscar "<pergunta>"` — recuperacao por relevancia.
3
+ *
4
+ * O contraponto da ancora: ela e exata e responde "que memorias falam deste arquivo?";
5
+ * esta responde "o que ja aprendemos sobre isto?", que e a pergunta de quem ainda esta
6
+ * entendendo o problema e nem sabe qual arquivo abrir.
7
+ *
8
+ * Devolve endereco, nao conteudo: quem quiser o corpo abre o arquivo que o `sync` ja
9
+ * materializou. Assim a busca continua barata e o Brain em disco continua sendo a fonte.
10
+ */
11
+ export type Achado = {
12
+ endereco: string;
13
+ titulo: string;
14
+ resumo: string;
15
+ };
16
+ export declare function busca(raiz: string, consulta: string, limite?: number): Promise<{
17
+ semantica: boolean;
18
+ provedor: string | null;
19
+ achados: Achado[];
20
+ }>;
package/dist/buscar.js CHANGED
@@ -1,21 +1,15 @@
1
- import { leConfigDoRepo, leToken } from "./config.js";
1
+ import { cabecalhos, credencial, pede, recusa } from "./api.js";
2
2
  export async function busca(raiz, consulta, limite) {
3
- const config = await leConfigDoRepo(raiz);
4
- const token = await leToken(config.api);
5
- if (!token) {
6
- throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
7
- }
3
+ const { config, token } = await credencial(raiz);
8
4
  const url = new URL(`${config.api}/api/v1/busca`);
9
5
  url.searchParams.set("tenant", config.tenant);
10
6
  url.searchParams.set("projeto", config.projeto);
11
7
  url.searchParams.set("q", consulta);
12
8
  if (limite)
13
9
  url.searchParams.set("limite", String(limite));
14
- const resposta = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
15
- if (!resposta.ok) {
16
- const { erro } = (await resposta.json().catch(() => ({})));
17
- throw new Error(erro ?? `a API respondeu ${resposta.status}.`);
18
- }
10
+ const resposta = await pede(url, { headers: cabecalhos(token) });
11
+ if (!resposta.ok)
12
+ await recusa(resposta);
19
13
  const lido = (await resposta.json());
20
14
  return { semantica: lido.semantica, provedor: lido.provedor, achados: lido.resultados };
21
15
  }
@@ -0,0 +1,63 @@
1
+ import { type Tocada } from "./diff.js";
2
+ import type { Brain } from "./materializa.js";
3
+ /**
4
+ * `dd-harness check` — resolve as âncoras contra a árvore de trabalho.
5
+ *
6
+ * Mede cada alvo e manda o resultado ao serviço, que julga. Nunca falha o processo por
7
+ * encontrar deriva: sai com 0 e imprime. Deriva avisa, não bloqueia — bloqueio antes de
8
+ * o mecanismo ter reputação ensina o reflexo do `--no-verify`.
9
+ */
10
+ export type Medicao = {
11
+ pasta: string;
12
+ memoria: string;
13
+ valor: string;
14
+ sha: string | null;
15
+ detalhe?: string;
16
+ };
17
+ export type ResultadoDoCheck = {
18
+ medidas: number;
19
+ ausentes: string[];
20
+ novas: number;
21
+ base: number;
22
+ jaAbertas: number;
23
+ /** Observacoes que fecharam sozinhas: o alvo voltou a bater com a linha de base. */
24
+ fechadas: number;
25
+ /** Memorias que falam do que o commit mudou. Vazio quando nao ha `--commit`. */
26
+ tocadas: Tocada[];
27
+ };
28
+ export type ResultadoDoStatus = {
29
+ /**
30
+ * O tamanho do acervo, por pasta. Sem isto, um agente que pergunta "o que ha no Brain
31
+ * deste projeto?" nao tem comando que responda — e a saida que resta e contar arquivo
32
+ * em disco, que acerta por acidente: memoria gravada e nao sincronizada nao esta la, e
33
+ * memoria arquivada sai do disco mas continua no indice, em `## Historico`.
34
+ */
35
+ acervo: {
36
+ ativas: number;
37
+ arquivadas: number;
38
+ porPasta: {
39
+ pasta: string;
40
+ quantas: number;
41
+ }[];
42
+ };
43
+ comDeriva: {
44
+ pasta: string;
45
+ memoria: string;
46
+ titulo: string;
47
+ abertas: number;
48
+ }[];
49
+ vencidas: {
50
+ pasta: string;
51
+ memoria: string;
52
+ titulo: string;
53
+ }[];
54
+ };
55
+ /** Separado do IO para poder ser testado sem rede: dado um Brain, o que se mede. */
56
+ export declare function medeAsAncoras(raiz: string, brain: Brain): Promise<Medicao[]>;
57
+ /**
58
+ * Somente leitura, de proposito: e o que roda na ABERTURA da sessao. Medir e reportar
59
+ * ali dentro faria toda sessao escrever no servico antes de a pessoa digitar qualquer
60
+ * coisa — caro, e o tipo de efeito colateral que ninguem espera de "abrir o editor".
61
+ */
62
+ export declare function status(raiz: string): Promise<ResultadoDoStatus>;
63
+ export declare function check(raiz: string, commit?: string): Promise<ResultadoDoCheck>;
package/dist/check.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { pede } from "./api.js";
1
2
  import { leConfigDoRepo, leToken } from "./config.js";
2
3
  import { caminhosDoCommit, memoriasTocadas } from "./diff.js";
3
4
  import { mede } from "./medir.js";
@@ -29,7 +30,7 @@ async function buscaOBrain(raiz) {
29
30
  throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
30
31
  }
31
32
  const parametros = `tenant=${encodeURIComponent(config.tenant)}&projeto=${encodeURIComponent(config.projeto)}`;
32
- const resposta = await fetch(`${config.api}/api/v1/artefatos?${parametros}`, {
33
+ const resposta = await pede(`${config.api}/api/v1/artefatos?${parametros}`, {
33
34
  headers: { Authorization: `Bearer ${token}` },
34
35
  });
35
36
  if (resposta.status === 401) {
@@ -49,7 +50,17 @@ export async function status(raiz) {
49
50
  const { brain } = await buscaOBrain(raiz);
50
51
  const agora = Date.now();
51
52
  const ativas = brain.memorias.filter((m) => m.status === "ativa");
53
+ const porPasta = new Map();
54
+ for (const m of ativas)
55
+ porPasta.set(m.pasta, (porPasta.get(m.pasta) ?? 0) + 1);
52
56
  return {
57
+ acervo: {
58
+ ativas: ativas.length,
59
+ arquivadas: brain.memorias.length - ativas.length,
60
+ porPasta: [...porPasta]
61
+ .map(([pasta, quantas]) => ({ pasta, quantas }))
62
+ .sort((a, b) => a.pasta.localeCompare(b.pasta)),
63
+ },
53
64
  comDeriva: ativas
54
65
  .filter((m) => (m.deriva_aberta ?? 0) > 0)
55
66
  .map((m) => ({
@@ -74,7 +85,7 @@ export async function check(raiz, commit) {
74
85
  if (medicoes.length === 0) {
75
86
  return { medidas: 0, ausentes: [], novas: 0, base: 0, jaAbertas: 0, fechadas: 0, tocadas };
76
87
  }
77
- const envio = await fetch(`${config.api}/api/v1/deriva`, {
88
+ const envio = await pede(`${config.api}/api/v1/deriva`, {
78
89
  method: "POST",
79
90
  headers: { ...cabecalhos, "Content-Type": "application/json" },
80
91
  body: JSON.stringify({
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Duas configuracoes, em lugares diferentes de proposito.
3
+ *
4
+ * `.dd-harness.json` fica **no repositorio** e e versionado: diz qual projeto aquele
5
+ * repo consome, e isso e informacao do repo, nao da pessoa.
6
+ *
7
+ * A credencial fica **na maquina** (`~/.dd-harness/credentials.json`, modo 0600) e nunca
8
+ * no repo: token e da pessoa e da maquina, e commitado seria segredo publicado.
9
+ */
10
+ export type ConfigDoRepo = {
11
+ api: string;
12
+ tenant: string;
13
+ projeto: string;
14
+ /** Onde o gerado vive. Muda-la move tudo de uma vez, menos o CLAUDE.md da raiz. */
15
+ pasta: string;
16
+ };
17
+ export declare const CAMINHO_CONFIG = ".dd-harness.json";
18
+ export declare const CAMINHO_MANIFESTO = "dd-harness/manifest.json";
19
+ export declare function leConfigDoRepo(raiz: string): Promise<ConfigDoRepo>;
20
+ /**
21
+ * Credencial por origem da API: a mesma maquina pode falar com uma instalacao local e
22
+ * com a de producao, e misturar token entre as duas daria 401 confuso.
23
+ */
24
+ export declare function leToken(api: string): Promise<string | null>;
25
+ export declare function guardaToken(api: string, token: string): Promise<string>;
26
+ export type Manifesto = {
27
+ etag: string | null;
28
+ arquivos: Record<string, string>;
29
+ };
30
+ export declare const manifestoVazio: () => Manifesto;
31
+ export declare function leManifesto(raiz: string): Promise<Manifesto>;
32
+ export declare function gravaManifesto(raiz: string, manifesto: Manifesto): Promise<void>;
33
+ export declare const hashDe: (conteudo: string) => string;
@@ -0,0 +1,12 @@
1
+ export declare function edita(raiz: string, caminho: string): Promise<{
2
+ endereco: string;
3
+ ancoras: number;
4
+ }>;
5
+ export type Arquivamento = {
6
+ motivo: "obsoleta" | "incorreta" | "fora_dos_filtros";
7
+ substituidaPor?: string;
8
+ };
9
+ export declare function arquiva(raiz: string, endereco: string, opcoes: Arquivamento): Promise<{
10
+ endereco: string;
11
+ motivo: string;
12
+ }>;
package/dist/curar.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { leConfigDoRepo, leToken } from "./config.js";
2
+ import { relative } from "node:path";
3
+ import { cabecalhos, credencial, pede, recusa } from "./api.js";
4
+ import { gravaManifesto, hashDe, leManifesto } from "./config.js";
3
5
  import { interpreta } from "./gravar.js";
4
6
  /**
5
7
  * Curadoria pelo agente: editar e arquivar.
@@ -12,25 +14,33 @@ import { interpreta } from "./gravar.js";
12
14
  * que o `sync` materializou e manda de volta. Um formato so para as duas operacoes, e o
13
15
  * que ele ja sabe ler.
14
16
  */
15
- async function credencial(raiz) {
16
- const config = await leConfigDoRepo(raiz);
17
- const token = await leToken(config.api);
18
- if (!token) {
19
- throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
20
- }
21
- return { config, token };
22
- }
23
- async function recusa(resposta) {
24
- const { erro } = (await resposta.json().catch(() => ({})));
25
- throw new Error(erro ?? `a API respondeu ${resposta.status}.`);
17
+ /**
18
+ * Depois que o servico aceita, o arquivo em disco deixa de ser "edicao nao enviada" — e o
19
+ * manifesto tem que saber, senao o `sync` seguinte para com "editado a mao" e a unica saida
20
+ * que resta e descartar o arquivo. O ciclo materializa-corrige-envia travava justamente
21
+ * aqui, com o servico ja atualizado.
22
+ *
23
+ * Guarda o hash do que foi enviado, nao do que o servico devolveria: o `sync` seguinte
24
+ * reescreve o arquivo na forma canonica quando o ETag mudar.
25
+ */
26
+ async function marcaComoEnviado(raiz, caminho, conteudo) {
27
+ const chave = relative(raiz, caminho).split("\\").join("/");
28
+ const manifesto = await leManifesto(raiz);
29
+ // Arquivo de fora do repositorio (um rascunho em /tmp, por exemplo) nao esta no
30
+ // manifesto e nao deve entrar: o manifesto descreve o que o `sync` materializou.
31
+ if (!manifesto.arquivos[chave])
32
+ return;
33
+ manifesto.arquivos[chave] = hashDe(conteudo);
34
+ await gravaManifesto(raiz, manifesto);
26
35
  }
27
36
  export async function edita(raiz, caminho) {
28
37
  const { config, token } = await credencial(raiz);
29
- const memoria = interpreta(await readFile(caminho, "utf8"));
38
+ const cru = await readFile(caminho, "utf8");
39
+ const memoria = interpreta(cru);
30
40
  const endereco = `${memoria.pasta}/${memoria.slug}`;
31
- const resposta = await fetch(`${config.api}/api/v1/memorias/${endereco}`, {
41
+ const resposta = await pede(`${config.api}/api/v1/memorias/${endereco}`, {
32
42
  method: "PATCH",
33
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
43
+ headers: cabecalhos(token, true),
34
44
  body: JSON.stringify({
35
45
  tenant: config.tenant,
36
46
  projeto: config.projeto,
@@ -45,13 +55,14 @@ export async function edita(raiz, caminho) {
45
55
  });
46
56
  if (!resposta.ok)
47
57
  await recusa(resposta);
58
+ await marcaComoEnviado(raiz, caminho, cru);
48
59
  return { endereco, ancoras: memoria.ancoras.length };
49
60
  }
50
61
  export async function arquiva(raiz, endereco, opcoes) {
51
62
  const { config, token } = await credencial(raiz);
52
- const resposta = await fetch(`${config.api}/api/v1/memorias/${endereco}`, {
63
+ const resposta = await pede(`${config.api}/api/v1/memorias/${endereco}`, {
53
64
  method: "DELETE",
54
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
65
+ headers: cabecalhos(token, true),
55
66
  body: JSON.stringify({
56
67
  tenant: config.tenant,
57
68
  projeto: config.projeto,
package/dist/diff.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Brain } from "./materializa.js";
2
+ /**
3
+ * Quais memorias falam do que este commit mudou.
4
+ *
5
+ * E a peca que devolve a memoria ao code review. Tirar o Brain do repositorio tirou o
6
+ * revisor que o diff dava de graca — no modelo file-based a memoria estava ali, no
7
+ * caminho de quem mexia. Aqui ela volta pelo cruzamento: caminho do diff contra ancora.
8
+ *
9
+ * Repare que isto NAO e deriva. Deriva e "o alvo mudou desde a linha de base"; isto e
10
+ * "voce acabou de mexer no que esta memoria guarda" — util mesmo quando a base ainda
11
+ * nem foi medida, e util mesmo que a memoria continue valendo.
12
+ */
13
+ /** Caminhos alterados num commit. Vazio quando o git nao responde — nunca quebra. */
14
+ export declare function caminhosDoCommit(raiz: string, commit: string): Promise<string[]>;
15
+ export type Tocada = {
16
+ pasta: string;
17
+ memoria: string;
18
+ titulo: string;
19
+ ancora: string;
20
+ };
21
+ /**
22
+ * Cruza caminhos com ancoras. A ancora casa quando e o proprio caminho ou quando e um
23
+ * diretorio que o contem — `supabase/migrations` tem que casar com a migration nova.
24
+ */
25
+ export declare function memoriasTocadas(brain: Brain, caminhos: string[]): Tocada[];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `dd-harness gravar <arquivo.md>` — o caminho pelo qual o agente registra memoria.
3
+ *
4
+ * A entrada e um markdown no mesmo formato que o `sync` materializa, porque e o formato
5
+ * que o agente ja sabe ler: frontmatter, corpo, os tres filtros, ancoras. Escrever um
6
+ * arquivo e o que ele fazia no modelo file-based; aqui o arquivo vira requisicao em vez
7
+ * de virar commit.
8
+ *
9
+ * O arquivo nao e deixado no repositorio: quem materializa e o `sync`, a partir do
10
+ * servico. Gravar e depois sincronizar traz a memoria de volta ja com o endereco real —
11
+ * e evita a divergencia de ter uma copia escrita a mao ao lado da copia gerada.
12
+ */
13
+ export type MemoriaLida = {
14
+ slug: string;
15
+ titulo: string;
16
+ resumo: string;
17
+ pasta: string;
18
+ corpo: string;
19
+ dano: string;
20
+ invisibilidade: string;
21
+ externalidade: string;
22
+ ancoras: string[];
23
+ tambemEm: string[];
24
+ };
25
+ export declare function interpreta(texto: string): MemoriaLida;
26
+ export declare function grava(raiz: string, caminho: string): Promise<{
27
+ endereco: string;
28
+ ancoras: number;
29
+ projetos: number;
30
+ }>;
package/dist/gravar.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { leConfigDoRepo, leToken } from "./config.js";
2
+ import { cabecalhos, credencial, pede, recusa } from "./api.js";
3
3
  const OBRIGATORIOS = ["name", "titulo", "description", "pasta"];
4
4
  /** Frontmatter simples: `chave: valor` por linha. Sem lib de YAML — nao ha aninhamento. */
5
5
  function leFrontmatter(texto) {
@@ -74,15 +74,11 @@ export function interpreta(texto) {
74
74
  };
75
75
  }
76
76
  export async function grava(raiz, caminho) {
77
- const config = await leConfigDoRepo(raiz);
78
- const token = await leToken(config.api);
79
- if (!token) {
80
- throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
81
- }
77
+ const { config, token } = await credencial(raiz);
82
78
  const memoria = interpreta(await readFile(caminho, "utf8"));
83
- const resposta = await fetch(`${config.api}/api/v1/memorias`, {
79
+ const resposta = await pede(`${config.api}/api/v1/memorias`, {
84
80
  method: "POST",
85
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
81
+ headers: cabecalhos(token, true),
86
82
  body: JSON.stringify({
87
83
  tenant: config.tenant,
88
84
  projeto: config.projeto,
@@ -98,10 +94,8 @@ export async function grava(raiz, caminho) {
98
94
  tambem_em: memoria.tambemEm,
99
95
  }),
100
96
  });
101
- if (!resposta.ok) {
102
- const { erro } = (await resposta.json().catch(() => ({})));
103
- throw new Error(erro ?? `a API respondeu ${resposta.status}.`);
104
- }
97
+ if (!resposta.ok)
98
+ await recusa(resposta);
105
99
  const { endereco } = (await resposta.json());
106
100
  return {
107
101
  endereco,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { check, status } from "./check.js";
3
3
  import { leConfigDoRepo, guardaToken } from "./config.js";
4
4
  import { grava } from "./gravar.js";
5
- import { init } from "./init.js";
5
+ import { init, SUGESTAO_MCP } from "./init.js";
6
6
  import { LINHA_DE_IMPORT } from "./materializa.js";
7
7
  import { busca } from "./buscar.js";
8
8
  import { arquiva, edita } from "./curar.js";
9
9
  import { criaPasta } from "./pasta.js";
10
+ import { criaProjeto } from "./projeto.js";
10
11
  import { sync } from "./sync.js";
11
12
  /**
12
13
  * `dd-harness` — o cliente que materializa os artefatos no repositorio.
@@ -16,26 +17,29 @@ import { sync } from "./sync.js";
16
17
  * em repositorio alheio tem que envelhecer bem, e cada dependencia e uma chance de nao
17
18
  * envelhecer.
18
19
  */
19
- const AJUDA = `dd-harness — materializa política, briefing e Brain no repositório
20
-
21
- dd-harness init --tenant <t> --projeto <p> [--api <url>]
22
- prepara o repositório (config + CLAUDE.md)
23
- dd-harness login --token <token> guarda a credencial desta máquina
24
- dd-harness pasta <slug> --definicao "o que entra e o que não entra"
25
- cria a pasta que o gravar exige
26
- dd-harness gravar <arquivo.md> registra uma memória a partir de um markdown
27
- dd-harness editar <arquivo.md> corrige o que está gravado
28
- dd-harness arquivar <pasta>/<slug> --motivo <obsoleta|incorreta|fora_dos_filtros>
29
- [--substituida-por <pasta>/<slug>]
30
- tira de circulação sem apagar
31
- dd-harness buscar "<pergunta>" acha memória por relevância, não por arquivo
32
- dd-harness sync escreve os artefatos em disco
33
- dd-harness check [--commit <sha>] mede as âncoras e reporta a deriva
34
- dd-harness status só lê: o que espera julgamento
35
- dd-harness --help
36
-
37
- O gerado vive em dd-harness/. Na raiz fica o seu CLAUDE.md, que importa a
38
- política com a linha ${LINHA_DE_IMPORT}
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}
39
43
  `;
40
44
  /** O aviso existe porque o import falha em silêncio — foi medido, não suposto. */
41
45
  function avisaSobreOPonteiro(ponteiro) {
@@ -72,17 +76,17 @@ function avisaSobreOPonteiro(ponteiro) {
72
76
  * nosso: escrever la dentro sem a pessoa pedir e o mesmo tipo de invasao que
73
77
  * sobrescrever o CLAUDE.md dela. Quem cola, decide.
74
78
  */
75
- const GANCHOS = `
76
- Opcional — dois ganchos que valem a pena:
77
-
78
- .git/hooks/post-commit (avisa quais memórias falam do que você mudou)
79
- #!/bin/sh
80
- dd-harness check --commit "$(git rev-parse HEAD)" || true
81
-
82
- .claude/settings.json (na abertura da sessão, o que espera julgamento)
83
- "hooks": { "SessionStart": [{ "hooks": [{ "type": "command",
84
- "command": "dd-harness status" }] }] }
85
-
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
+
86
90
  Os dois terminam em sucesso mesmo com deriva: avisam, não bloqueiam.`;
87
91
  function argumento(argv, nome) {
88
92
  const i = argv.indexOf(`--${nome}`);
@@ -106,14 +110,30 @@ async function comandoInit(argv) {
106
110
  "ja-tinha-a-linha": "mantido CLAUDE.md — já importava a política",
107
111
  }[r.claudeMd]);
108
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);
109
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
+ */
110
128
  async function comandoLogin(argv) {
111
129
  const token = argumento(argv, "token");
112
130
  if (!token)
113
- throw new Error("uso: dd-harness login --token <token>");
114
- const config = await leConfigDoRepo(process.cwd());
115
- const caminho = await guardaToken(config.api, token);
116
- console.log(`credencial de ${config.api} guardada em ${caminho}`);
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}`);
117
137
  }
118
138
  async function comandoEditar(argv) {
119
139
  const caminho = argv[0];
@@ -173,8 +193,11 @@ async function comandoSync() {
173
193
  "parei sem escrever nada: estes arquivos foram editados à mão.",
174
194
  ...resultado.arquivos.map((a) => ` ${a}`),
175
195
  "",
176
- "O disco é projeção do serviço, uma direção só. Leve a mudança para o serviço,",
177
- "ou descarte a edição local (git checkout / apague o arquivo) e sincronize de novo.",
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.",
178
201
  ].join("\n"));
179
202
  process.exitCode = 1;
180
203
  return;
@@ -240,10 +263,24 @@ async function comandoCheck(argv) {
240
263
  }
241
264
  async function comandoStatus() {
242
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
+ }
243
279
  if (!r.comDeriva.length && !r.vencidas.length) {
244
- console.log("dd-harness: nada esperando julgamento.");
280
+ console.log("Nada esperando julgamento.");
245
281
  return;
246
282
  }
283
+ console.log("");
247
284
  if (r.comDeriva.length) {
248
285
  const total = r.comDeriva.reduce((soma, m) => soma + m.abertas, 0);
249
286
  console.log(`dd-harness: ${total} deriva(s) aberta(s), esperando julgamento:`);
@@ -258,6 +295,25 @@ async function comandoStatus() {
258
295
  console.log(` ${m.pasta}/${m.memoria} — ${m.titulo}`);
259
296
  }
260
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
+ }
261
317
  async function comandoPasta(argv) {
262
318
  const slug = argv[0];
263
319
  const definicao = argumento(argv, "definicao");
@@ -292,6 +348,8 @@ async function principal() {
292
348
  return comandoInit(resto);
293
349
  case "login":
294
350
  return comandoLogin(resto);
351
+ case "projeto":
352
+ return comandoProjeto(resto);
295
353
  case "pasta":
296
354
  return comandoPasta(resto);
297
355
  case "gravar":
package/dist/init.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export type ResultadoDoInit = {
2
+ config: "criada" | "ja-existia";
3
+ claudeMd: "criado" | "linha-acrescentada" | "ja-tinha-a-linha";
4
+ mcp: "ja-declarado" | "a-declarar";
5
+ };
6
+ /**
7
+ * O `.mcp.json` e sugerido, nunca escrito.
8
+ *
9
+ * Pelo mesmo motivo do `CLAUDE.md` da raiz: o arquivo e do repositorio, pode ja declarar
10
+ * outros servidores, e mesclar JSON alheio e onde falha silenciosa nasce — entrada errada
11
+ * nao da erro, a ferramenta so nao aparece. Quem cola sabe o que colou.
12
+ */
13
+ export declare const SUGESTAO_MCP = "{\n \"mcpServers\": {\n \"dd-harness\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"dd-harness-mcp\"]\n }\n }\n}";
14
+ export declare function init(raiz: string, dados: {
15
+ tenant: string;
16
+ projeto: string;
17
+ api?: string;
18
+ }): Promise<ResultadoDoInit>;
package/dist/init.js CHANGED
@@ -10,13 +10,39 @@ import { LINHA_DE_IMPORT } from "./materializa.js";
10
10
  * acrescenta a linha ao que voce escreveu, sem tocar no resto. Adotar um projeto vira
11
11
  * uma linha, e nao um ritual de mover arquivo.
12
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.
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
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
+ }
20
46
  export async function init(raiz, dados) {
21
47
  const caminhoConfig = join(raiz, CAMINHO_CONFIG);
22
48
  let config = "ja-existia";
@@ -54,5 +80,5 @@ export async function init(raiz, dados) {
54
80
  await writeFile(caminhoClaude, `${atual}${separador}${LINHA_DE_IMPORT}\n`, "utf8");
55
81
  claudeMd = "linha-acrescentada";
56
82
  }
57
- return { config, claudeMd };
83
+ return { config, claudeMd, mcp: await declaraMcp(raiz) };
58
84
  }
@@ -0,0 +1,65 @@
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
+ export type Ancora = {
10
+ tipo: string;
11
+ valor: string;
12
+ sha: string | null;
13
+ };
14
+ export type Memoria = {
15
+ pasta: string;
16
+ slug: string;
17
+ titulo: string;
18
+ resumo: string;
19
+ corpo: string;
20
+ status: "ativa" | "historico";
21
+ dano: string;
22
+ invisibilidade: string;
23
+ externalidade: string;
24
+ ancoras: Ancora[];
25
+ revisar_ate: string | null;
26
+ /** Observacoes de deriva esperando julgamento. Ausente em payload antigo. */
27
+ deriva_aberta?: number;
28
+ };
29
+ export type Brain = {
30
+ tenant: {
31
+ slug: string;
32
+ nome: string;
33
+ };
34
+ projeto: {
35
+ slug: string;
36
+ nome: string;
37
+ };
38
+ /** `CLAUDE.md`. Nulo ou vazio: ainda nao existe, e nao vira arquivo. */
39
+ politica?: string | null;
40
+ /** `BRIEFING.md`. Mesma regra. */
41
+ briefing?: string | null;
42
+ pastas: {
43
+ slug: string;
44
+ definicao: string;
45
+ }[];
46
+ memorias: Memoria[];
47
+ };
48
+ export declare function arquivoDaMemoria(m: Memoria): string;
49
+ export declare function arquivoDoIndice(brain: Brain): string;
50
+ /** Tudo o que o servico gera vive aqui — e nada fora daqui e escrito pelo `sync`. */
51
+ export declare const PASTA = "dd-harness";
52
+ /** O que a raiz precisa conter para a politica chegar a sessao. */
53
+ export declare const LINHA_DE_IMPORT = "@dd-harness/politica.md";
54
+ /**
55
+ * Caminho relativo (POSIX) -> conteúdo. As chaves são o que o manifesto guarda.
56
+ *
57
+ * Tudo dentro de `dd-harness/`, inclusive a política. Na raiz fica só o `CLAUDE.md`, que
58
+ * é **seu**: o `sync` não o escreve, apenas confere que ele importa a política. É o que
59
+ * deixa conviverem a parte gerenciada e o que aquele repositório tem de próprio — e o
60
+ * que faz adotar um projeto existente ser uma linha, não um ritual.
61
+ *
62
+ * Artefato vazio ou ausente não entra no mapa; como o manifesto remove o que saiu do
63
+ * conjunto, esvaziar no serviço apaga o arquivo no próximo `sync`.
64
+ */
65
+ export declare function materializa(brain: Brain, pasta?: string): Map<string, string>;
@@ -28,9 +28,14 @@ function secaoDeFiltros(m) {
28
28
  ].join("\n");
29
29
  }
30
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.
31
35
  const frontmatter = [
32
36
  "---",
33
37
  `name: ${m.slug}`,
38
+ `titulo: ${m.titulo.replace(/\n/g, " ")}`,
34
39
  `description: ${m.resumo.replace(/\n/g, " ")}`,
35
40
  `pasta: ${m.pasta}`,
36
41
  ...(m.revisar_ate ? [`revisar-ate: ${m.revisar_ate.slice(0, 10)}`] : []),
@@ -0,0 +1 @@
1
+ export declare function mede(raiz: string, valor: string): Promise<string | null>;
package/dist/medir.js CHANGED
@@ -32,7 +32,48 @@ async function hashDeDiretorio(caminho) {
32
32
  await anda(caminho, "");
33
33
  return createHash("sha256").update(nomes.sort().join("\n")).digest("hex");
34
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
+ }
35
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
+ }
36
77
  const caminho = join(raiz, valor);
37
78
  try {
38
79
  const info = await stat(caminho);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `dd-harness pasta <slug> --definicao "..."` — cria a pasta sem sair da sessao.
3
+ *
4
+ * `gravar` recusa quando a pasta nao existe, e essa recusa e boa: e ela que impede um
5
+ * typo no `pasta:` de virar pasta nova em silencio. O que faltava era a saida — ate aqui
6
+ * o agente parava e pedia que alguem abrisse o navegador.
7
+ */
8
+ export declare function criaPasta(raiz: string, slug: string, definicao: string): Promise<{
9
+ pasta: string;
10
+ jaExistia: boolean;
11
+ }>;
package/dist/pasta.js CHANGED
@@ -1,4 +1,4 @@
1
- import { leConfigDoRepo, leToken } from "./config.js";
1
+ import { cabecalhos, credencial, pede, recusa } from "./api.js";
2
2
  /**
3
3
  * `dd-harness pasta <slug> --definicao "..."` — cria a pasta sem sair da sessao.
4
4
  *
@@ -7,14 +7,10 @@ import { leConfigDoRepo, leToken } from "./config.js";
7
7
  * o agente parava e pedia que alguem abrisse o navegador.
8
8
  */
9
9
  export async function criaPasta(raiz, slug, definicao) {
10
- const config = await leConfigDoRepo(raiz);
11
- const token = await leToken(config.api);
12
- if (!token) {
13
- throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
14
- }
15
- const resposta = await fetch(`${config.api}/api/v1/pastas`, {
10
+ const { config, token } = await credencial(raiz);
11
+ const resposta = await pede(`${config.api}/api/v1/pastas`, {
16
12
  method: "POST",
17
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
13
+ headers: cabecalhos(token, true),
18
14
  body: JSON.stringify({
19
15
  tenant: config.tenant,
20
16
  projeto: config.projeto,
@@ -27,10 +23,8 @@ export async function criaPasta(raiz, slug, definicao) {
27
23
  // nao criou nada.
28
24
  if (resposta.status === 409)
29
25
  return { pasta: slug, jaExistia: true };
30
- if (!resposta.ok) {
31
- const { erro } = (await resposta.json().catch(() => ({})));
32
- throw new Error(erro ?? `a API respondeu ${resposta.status}.`);
33
- }
26
+ if (!resposta.ok)
27
+ await recusa(resposta);
34
28
  const { pasta } = (await resposta.json());
35
29
  return { pasta, jaExistia: false };
36
30
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `dd-harness projeto <slug> --nome "..."` — cria o projeto sem sair da sessao.
3
+ *
4
+ * Era o ultimo passo do espaco que exigia navegador. O tenant continua manual de
5
+ * proposito (fronteira de isolamento e ato de dono), mas daqui para baixo o agente que
6
+ * abre um repositorio novo consegue preparar tudo: projeto, pasta e memoria.
7
+ *
8
+ * Nao escreve o `.dd-harness.json`: quem faz isso e o `init`, e ele precisa de um projeto
9
+ * que ja exista. A ordem e `projeto` e depois `init` — e por isso este comando NAO pode
10
+ * exigir o config do repo, que nesse momento ainda nao existe. Daí `--tenant` e `--api`:
11
+ * sem config, eles vem da linha de comando; com config, ele preenche o que faltar.
12
+ */
13
+ export declare function criaProjeto(raiz: string, slug: string, nome: string, explicito?: {
14
+ tenant?: string;
15
+ api?: string;
16
+ }): Promise<{
17
+ projeto: string;
18
+ jaExistia: boolean;
19
+ }>;
@@ -0,0 +1,39 @@
1
+ import { cabecalhos, pede, recusa } from "./api.js";
2
+ import { leConfigDoRepo, leToken } from "./config.js";
3
+ /**
4
+ * `dd-harness projeto <slug> --nome "..."` — cria o projeto sem sair da sessao.
5
+ *
6
+ * Era o ultimo passo do espaco que exigia navegador. O tenant continua manual de
7
+ * proposito (fronteira de isolamento e ato de dono), mas daqui para baixo o agente que
8
+ * abre um repositorio novo consegue preparar tudo: projeto, pasta e memoria.
9
+ *
10
+ * Nao escreve o `.dd-harness.json`: quem faz isso e o `init`, e ele precisa de um projeto
11
+ * que ja exista. A ordem e `projeto` e depois `init` — e por isso este comando NAO pode
12
+ * exigir o config do repo, que nesse momento ainda nao existe. Daí `--tenant` e `--api`:
13
+ * sem config, eles vem da linha de comando; com config, ele preenche o que faltar.
14
+ */
15
+ export async function criaProjeto(raiz, slug, nome, explicito = {}) {
16
+ const doRepo = await leConfigDoRepo(raiz).catch(() => null);
17
+ const tenant = explicito.tenant ?? doRepo?.tenant;
18
+ const api = (explicito.api ?? doRepo?.api ?? "https://dd-harness.vercel.app").replace(/\/$/, "");
19
+ if (!tenant) {
20
+ throw new Error("não sei em que espaço criar: passe `--tenant <slug>` (ou rode dentro de um repositório já com .dd-harness.json).");
21
+ }
22
+ const token = await leToken(api);
23
+ if (!token) {
24
+ throw new Error(`sem credencial para ${api}. Rode \`dd-harness login --token <token>\`.`);
25
+ }
26
+ const resposta = await pede(`${api}/api/v1/projetos`, {
27
+ method: "POST",
28
+ headers: cabecalhos(token, true),
29
+ body: JSON.stringify({ tenant, slug, nome }),
30
+ });
31
+ // Como em `pasta`: projeto que ja existe nao e falha para quem chamou, o estado desejado
32
+ // ja vale. Vale dizer que existia — quem pediu pode estar corrigindo um typo.
33
+ if (resposta.status === 409)
34
+ return { projeto: slug, jaExistia: true };
35
+ if (!resposta.ok)
36
+ await recusa(resposta);
37
+ const { projeto } = (await resposta.json());
38
+ return { projeto, jaExistia: false };
39
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Busca o Brain e escreve em disco. Uma direcao so.
3
+ *
4
+ * O manifesto guarda o hash de cada arquivo que este comando escreveu. Antes de
5
+ * sobrescrever, ele confere: hash igual ao guardado significa "isto e meu, posso
6
+ * reescrever"; hash diferente significa que alguem editou a mao, e ai o comando **para**.
7
+ * Deriva silenciosa entre repo e servico e exatamente o que este projeto existe para
8
+ * evitar — melhor falhar alto do que engolir a edicao de alguem.
9
+ */
10
+ /**
11
+ * Estado do ponteiro na raiz.
12
+ *
13
+ * `CLAUDE.md` nao e gerado — e do repositorio, e so precisa importar a politica. Mas o
14
+ * import falha em **silencio**: sem o arquivo alvo, ou sem a linha, a sessao abre e
15
+ * nada avisa que a politica nao veio junto. Foi medido, nao suposto. Como a sessao sem
16
+ * protocolo e a falha que o molde mais combate, o `sync` confere isso toda vez — mesmo
17
+ * quando o servico nao mudou, porque quem apaga a linha e quem mexe no repositorio.
18
+ */
19
+ export type Ponteiro = "ok" | "sem-claude-md" | "sem-a-linha" | "sem-politica"
20
+ /**
21
+ * A linha de import existe, mas nao ha politica no servico para ela apontar. O `init`
22
+ * escreve a linha antes de existir politica, entao o repositorio novo cai aqui: import
23
+ * pendurado desde o primeiro dia, e o mesmo silencio de sempre.
24
+ */
25
+ | "aponta-para-o-vazio";
26
+ type Resultado = {
27
+ ponteiro: Ponteiro;
28
+ } & ({
29
+ tipo: "sem-mudanca";
30
+ } | {
31
+ tipo: "sincronizado";
32
+ escritos: string[];
33
+ removidos: string[];
34
+ } | {
35
+ tipo: "editado-a-mao";
36
+ arquivos: string[];
37
+ });
38
+ /**
39
+ * Estado do que este comando escreveu, contra o hash guardado.
40
+ *
41
+ * Duas situacoes diferentes, e confundi-las custa caro: hash diferente e **edicao a
42
+ * mao**, e o comando tem que parar sem escrever; arquivo **ausente** nao e edicao — e
43
+ * trabalho a refazer, e o comando tem que reescrever.
44
+ */
45
+ export declare function confereDisco(raiz: string, arquivos: Record<string, string>): Promise<{
46
+ editados: string[];
47
+ faltando: string[];
48
+ }>;
49
+ /**
50
+ * O `CLAUDE.md` da raiz existe e importa a politica?
51
+ *
52
+ * Sem politica no servico E sem a linha, nao ha o que apontar nem o que avisar — avisar
53
+ * ai seria ruido que ensina a ignorar aviso. Mas com a linha escrita e a politica vazia o
54
+ * import esta pendurado de verdade, e esse e o silencio que a memoria
55
+ * `import-do-claude-md-falha-calado` manda quebrar.
56
+ */
57
+ export declare function confereOPonteiro(raiz: string, temPolitica: boolean): Promise<Ponteiro>;
58
+ export declare function sync(raiz: string): Promise<Resultado>;
59
+ export {};
package/dist/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { pede } from "./api.js";
2
3
  import { dirname, join } from "node:path";
3
4
  import { gravaManifesto, hashDe, leConfigDoRepo, leManifesto, leToken, } from "./config.js";
4
5
  import { LINHA_DE_IMPORT, materializa } from "./materializa.js";
@@ -31,7 +32,7 @@ export async function confereDisco(raiz, arquivos) {
31
32
  }
32
33
  async function buscaBrain(config, token, etag) {
33
34
  const url = `${config.api}/api/v1/artefatos?tenant=${encodeURIComponent(config.tenant)}&projeto=${encodeURIComponent(config.projeto)}`;
34
- const resposta = await fetch(url, {
35
+ const resposta = await pede(url, {
35
36
  headers: {
36
37
  Authorization: `Bearer ${token}`,
37
38
  ...(etag ? { "If-None-Match": etag } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dd-harness",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Materializa politica, briefing e Brain do dd-harness no repositorio. Sem dependencia: fetch, crypto e fs sao do Node.",
6
6
  "license": "UNLICENSED",
@@ -21,6 +21,15 @@
21
21
  "bin": {
22
22
  "dd-harness": "dist/index.js"
23
23
  },
24
+ "//exports": "Aponta para `dist` porque quem IMPORTA isto em tempo de execucao e o Node, que nao executa TypeScript. Nao aponte para `src`: o Node segue os imports relativos de dentro do arquivo e tenta abrir `./api.js` ao lado do `.ts`, que nao existe. Quem consome no workspace e o `packages/mcp`, e ele compila o fonte do CLI junto (ver o tsconfig.build.json dele) em vez de depender deste campo — assim o build funciona num checkout limpo, sem `dist` previo.",
25
+ "exports": {
26
+ "./api": "./dist/api.js",
27
+ "./buscar": "./dist/buscar.js",
28
+ "./curar": "./dist/curar.js",
29
+ "./gravar": "./dist/gravar.js",
30
+ "./pasta": "./dist/pasta.js",
31
+ "./projeto": "./dist/projeto.js"
32
+ },
24
33
  "files": [
25
34
  "dist"
26
35
  ],