dd-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/README.md +64 -0
- package/dist/check.js +102 -0
- package/dist/config.js +81 -0
- package/dist/diff.js +52 -0
- package/dist/index.js +205 -0
- package/dist/init.js +58 -0
- package/dist/materializa.js +120 -0
- package/dist/medir.js +46 -0
- package/dist/sync.js +149 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +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.
|
package/README.md
ADDED
|
@@ -0,0 +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.
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { leConfigDoRepo, leToken } from "./config.js";
|
|
2
|
+
import { caminhosDoCommit, memoriasTocadas } from "./diff.js";
|
|
3
|
+
import { mede } from "./medir.js";
|
|
4
|
+
/** Separado do IO para poder ser testado sem rede: dado um Brain, o que se mede. */
|
|
5
|
+
export async function medeAsAncoras(raiz, brain) {
|
|
6
|
+
const medicoes = [];
|
|
7
|
+
for (const memoria of brain.memorias) {
|
|
8
|
+
// Memória arquivada não interessa: ela já saiu de circulação, e medir o alvo dela
|
|
9
|
+
// encheria a fila de deriva que ninguém vai resolver.
|
|
10
|
+
if (memoria.status !== "ativa")
|
|
11
|
+
continue;
|
|
12
|
+
for (const ancora of memoria.ancoras) {
|
|
13
|
+
const sha = await mede(raiz, ancora.valor);
|
|
14
|
+
medicoes.push({
|
|
15
|
+
pasta: memoria.pasta,
|
|
16
|
+
memoria: memoria.slug,
|
|
17
|
+
valor: ancora.valor,
|
|
18
|
+
sha,
|
|
19
|
+
...(sha === null ? { detalhe: "alvo não encontrado na árvore de trabalho" } : {}),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return medicoes;
|
|
24
|
+
}
|
|
25
|
+
async function buscaOBrain(raiz) {
|
|
26
|
+
const config = await leConfigDoRepo(raiz);
|
|
27
|
+
const token = await leToken(config.api);
|
|
28
|
+
if (!token) {
|
|
29
|
+
throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
|
|
30
|
+
}
|
|
31
|
+
const parametros = `tenant=${encodeURIComponent(config.tenant)}&projeto=${encodeURIComponent(config.projeto)}`;
|
|
32
|
+
const resposta = await fetch(`${config.api}/api/v1/artefatos?${parametros}`, {
|
|
33
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
34
|
+
});
|
|
35
|
+
if (resposta.status === 401) {
|
|
36
|
+
throw new Error("token recusado. Rode `dd-harness login --token <token>`.");
|
|
37
|
+
}
|
|
38
|
+
if (!resposta.ok) {
|
|
39
|
+
throw new Error(`a API respondeu ${resposta.status} ao buscar as âncoras.`);
|
|
40
|
+
}
|
|
41
|
+
return { config, token, brain: (await resposta.json()) };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Somente leitura, de proposito: e o que roda na ABERTURA da sessao. Medir e reportar
|
|
45
|
+
* ali dentro faria toda sessao escrever no servico antes de a pessoa digitar qualquer
|
|
46
|
+
* coisa — caro, e o tipo de efeito colateral que ninguem espera de "abrir o editor".
|
|
47
|
+
*/
|
|
48
|
+
export async function status(raiz) {
|
|
49
|
+
const { brain } = await buscaOBrain(raiz);
|
|
50
|
+
const agora = Date.now();
|
|
51
|
+
const ativas = brain.memorias.filter((m) => m.status === "ativa");
|
|
52
|
+
return {
|
|
53
|
+
comDeriva: ativas
|
|
54
|
+
.filter((m) => (m.deriva_aberta ?? 0) > 0)
|
|
55
|
+
.map((m) => ({
|
|
56
|
+
pasta: m.pasta,
|
|
57
|
+
memoria: m.slug,
|
|
58
|
+
titulo: m.titulo,
|
|
59
|
+
abertas: m.deriva_aberta ?? 0,
|
|
60
|
+
})),
|
|
61
|
+
vencidas: ativas
|
|
62
|
+
.filter((m) => m.revisar_ate && new Date(m.revisar_ate).getTime() < agora)
|
|
63
|
+
.map((m) => ({ pasta: m.pasta, memoria: m.slug, titulo: m.titulo })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function check(raiz, commit) {
|
|
67
|
+
const { config, token, brain } = await buscaOBrain(raiz);
|
|
68
|
+
const cabecalhos = { Authorization: `Bearer ${token}` };
|
|
69
|
+
const medicoes = await medeAsAncoras(raiz, brain);
|
|
70
|
+
// O cruzamento com o diff so faz sentido quando ha um commit para olhar.
|
|
71
|
+
const tocadas = commit
|
|
72
|
+
? memoriasTocadas(brain, await caminhosDoCommit(raiz, commit))
|
|
73
|
+
: [];
|
|
74
|
+
if (medicoes.length === 0) {
|
|
75
|
+
return { medidas: 0, ausentes: [], novas: 0, base: 0, jaAbertas: 0, tocadas };
|
|
76
|
+
}
|
|
77
|
+
const envio = await fetch(`${config.api}/api/v1/deriva`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { ...cabecalhos, "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
tenant: config.tenant,
|
|
82
|
+
projeto: config.projeto,
|
|
83
|
+
commit: commit ?? null,
|
|
84
|
+
medicoes,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
if (envio.status === 403) {
|
|
88
|
+
throw new Error("este token não pode reportar deriva. Crie outro na interface e refaça o login.");
|
|
89
|
+
}
|
|
90
|
+
if (!envio.ok) {
|
|
91
|
+
throw new Error(`a API respondeu ${envio.status} ao registrar as medições.`);
|
|
92
|
+
}
|
|
93
|
+
const julgamento = (await envio.json());
|
|
94
|
+
return {
|
|
95
|
+
medidas: medicoes.length,
|
|
96
|
+
ausentes: medicoes.filter((m) => m.sha === null).map((m) => m.valor).sort(),
|
|
97
|
+
novas: julgamento.novas,
|
|
98
|
+
base: julgamento.base,
|
|
99
|
+
jaAbertas: julgamento.ja_abertas,
|
|
100
|
+
tocadas,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* O servico publicado e o padrao: um repositorio que so declara `tenant` e `projeto` ja
|
|
7
|
+
* fala com quem existe. Para desenvolver contra a maquina local, ponha `api` explicito no
|
|
8
|
+
* `.dd-harness.json` — apontar para `localhost` por omissao deixaria todo repo novo
|
|
9
|
+
* falando com um servidor que ninguem esta rodando.
|
|
10
|
+
*/
|
|
11
|
+
const PADRAO = { api: "https://dd-harness.vercel.app", pasta: "dd-harness" };
|
|
12
|
+
export const CAMINHO_CONFIG = ".dd-harness.json";
|
|
13
|
+
export const CAMINHO_MANIFESTO = "dd-harness/manifest.json";
|
|
14
|
+
export async function leConfigDoRepo(raiz) {
|
|
15
|
+
let cru;
|
|
16
|
+
try {
|
|
17
|
+
cru = await readFile(join(raiz, CAMINHO_CONFIG), "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`não encontrei ${CAMINHO_CONFIG} em ${raiz}. Crie com: {"tenant":"...","projeto":"..."}`);
|
|
21
|
+
}
|
|
22
|
+
const lido = JSON.parse(cru);
|
|
23
|
+
if (!lido.tenant || !lido.projeto) {
|
|
24
|
+
throw new Error(`${CAMINHO_CONFIG} precisa de "tenant" e "projeto".`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
api: (lido.api ?? PADRAO.api).replace(/\/$/, ""),
|
|
28
|
+
tenant: lido.tenant,
|
|
29
|
+
projeto: lido.projeto,
|
|
30
|
+
pasta: lido.pasta ?? PADRAO.pasta,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const arquivoDeCredenciais = () => join(homedir(), ".dd-harness", "credentials.json");
|
|
34
|
+
/**
|
|
35
|
+
* Credencial por origem da API: a mesma maquina pode falar com uma instalacao local e
|
|
36
|
+
* com a de producao, e misturar token entre as duas daria 401 confuso.
|
|
37
|
+
*/
|
|
38
|
+
export async function leToken(api) {
|
|
39
|
+
try {
|
|
40
|
+
const cru = await readFile(arquivoDeCredenciais(), "utf8");
|
|
41
|
+
const mapa = JSON.parse(cru);
|
|
42
|
+
return mapa[api] ?? null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function guardaToken(api, token) {
|
|
49
|
+
const caminho = arquivoDeCredenciais();
|
|
50
|
+
await mkdir(join(homedir(), ".dd-harness"), { recursive: true });
|
|
51
|
+
let mapa = {};
|
|
52
|
+
try {
|
|
53
|
+
mapa = JSON.parse(await readFile(caminho, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Primeiro token desta maquina.
|
|
57
|
+
}
|
|
58
|
+
mapa[api] = token;
|
|
59
|
+
await writeFile(caminho, `${JSON.stringify(mapa, null, 2)}\n`, "utf8");
|
|
60
|
+
// 0600 depois de escrever. No Windows o chmod e no-op — o arquivo herda a ACL do
|
|
61
|
+
// perfil do usuario, que ja e privada; em POSIX sem isto o token nasce legivel por
|
|
62
|
+
// qualquer processo do sistema.
|
|
63
|
+
await chmod(caminho, 0o600).catch(() => { });
|
|
64
|
+
return caminho;
|
|
65
|
+
}
|
|
66
|
+
export const manifestoVazio = () => ({ etag: null, arquivos: {} });
|
|
67
|
+
export async function leManifesto(raiz) {
|
|
68
|
+
try {
|
|
69
|
+
const cru = await readFile(join(raiz, CAMINHO_MANIFESTO), "utf8");
|
|
70
|
+
const lido = JSON.parse(cru);
|
|
71
|
+
return { etag: lido.etag ?? null, arquivos: lido.arquivos ?? {} };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return manifestoVazio();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function gravaManifesto(raiz, manifesto) {
|
|
78
|
+
await mkdir(join(raiz, "dd-harness"), { recursive: true });
|
|
79
|
+
await writeFile(join(raiz, CAMINHO_MANIFESTO), `${JSON.stringify(manifesto, null, 2)}\n`, "utf8");
|
|
80
|
+
}
|
|
81
|
+
export const hashDe = (conteudo) => createHash("sha256").update(conteudo, "utf8").digest("hex");
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const roda = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Quais memorias falam do que este commit mudou.
|
|
6
|
+
*
|
|
7
|
+
* E a peca que devolve a memoria ao code review. Tirar o Brain do repositorio tirou o
|
|
8
|
+
* revisor que o diff dava de graca — no modelo file-based a memoria estava ali, no
|
|
9
|
+
* caminho de quem mexia. Aqui ela volta pelo cruzamento: caminho do diff contra ancora.
|
|
10
|
+
*
|
|
11
|
+
* Repare que isto NAO e deriva. Deriva e "o alvo mudou desde a linha de base"; isto e
|
|
12
|
+
* "voce acabou de mexer no que esta memoria guarda" — util mesmo quando a base ainda
|
|
13
|
+
* nem foi medida, e util mesmo que a memoria continue valendo.
|
|
14
|
+
*/
|
|
15
|
+
/** Caminhos alterados num commit. Vazio quando o git nao responde — nunca quebra. */
|
|
16
|
+
export async function caminhosDoCommit(raiz, commit) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await roda("git", ["diff-tree", "--no-commit-id", "--name-only", "-r", commit], { cwd: raiz });
|
|
19
|
+
return stdout
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.map((l) => l.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Sem git, commit inexistente, repositorio raso: o cruzamento simplesmente nao
|
|
26
|
+
// acontece. Um aviso que nao pode ser dado nao vira erro que atrapalha o commit.
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Cruza caminhos com ancoras. A ancora casa quando e o proprio caminho ou quando e um
|
|
32
|
+
* diretorio que o contem — `supabase/migrations` tem que casar com a migration nova.
|
|
33
|
+
*/
|
|
34
|
+
export function memoriasTocadas(brain, caminhos) {
|
|
35
|
+
const tocadas = [];
|
|
36
|
+
for (const memoria of brain.memorias) {
|
|
37
|
+
if (memoria.status !== "ativa")
|
|
38
|
+
continue;
|
|
39
|
+
for (const ancora of memoria.ancoras) {
|
|
40
|
+
const casa = caminhos.some((c) => c === ancora.valor || c.startsWith(`${ancora.valor}/`));
|
|
41
|
+
if (casa) {
|
|
42
|
+
tocadas.push({
|
|
43
|
+
pasta: memoria.pasta,
|
|
44
|
+
memoria: memoria.slug,
|
|
45
|
+
titulo: memoria.titulo,
|
|
46
|
+
ancora: ancora.valor,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return tocadas;
|
|
52
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { check, status } from "./check.js";
|
|
3
|
+
import { leConfigDoRepo, guardaToken } from "./config.js";
|
|
4
|
+
import { init } from "./init.js";
|
|
5
|
+
import { LINHA_DE_IMPORT } from "./materializa.js";
|
|
6
|
+
import { sync } from "./sync.js";
|
|
7
|
+
/**
|
|
8
|
+
* `dd-harness` — o cliente que materializa os artefatos no repositorio.
|
|
9
|
+
*
|
|
10
|
+
* Sem dependencia de proposito: `fetch`, `crypto` e `fs` sao do Node, e o molde original
|
|
11
|
+
* ja seguia a regra de validador determinístico sem dependencia. Um CLI que vive pinado
|
|
12
|
+
* em repositorio alheio tem que envelhecer bem, e cada dependencia e uma chance de nao
|
|
13
|
+
* envelhecer.
|
|
14
|
+
*/
|
|
15
|
+
const AJUDA = `dd-harness — materializa política, briefing e Brain no repositório
|
|
16
|
+
|
|
17
|
+
dd-harness init --tenant <t> --projeto <p> [--api <url>]
|
|
18
|
+
prepara o repositório (config + CLAUDE.md)
|
|
19
|
+
dd-harness login --token <token> guarda a credencial desta máquina
|
|
20
|
+
dd-harness sync escreve os artefatos em disco
|
|
21
|
+
dd-harness check [--commit <sha>] mede as âncoras e reporta a deriva
|
|
22
|
+
dd-harness status só lê: o que espera julgamento
|
|
23
|
+
dd-harness --help
|
|
24
|
+
|
|
25
|
+
O gerado vive em dd-harness/. Na raiz fica só o seu CLAUDE.md, que importa a
|
|
26
|
+
política com a linha ${LINHA_DE_IMPORT}
|
|
27
|
+
`;
|
|
28
|
+
/** O aviso existe porque o import falha em silêncio — foi medido, não suposto. */
|
|
29
|
+
function avisaSobreOPonteiro(ponteiro) {
|
|
30
|
+
if (ponteiro === "ok" || ponteiro === "sem-politica")
|
|
31
|
+
return;
|
|
32
|
+
const motivo = ponteiro === "sem-claude-md"
|
|
33
|
+
? "não há CLAUDE.md na raiz"
|
|
34
|
+
: "o CLAUDE.md da raiz não importa a política";
|
|
35
|
+
console.error([
|
|
36
|
+
"",
|
|
37
|
+
`AVISO: ${motivo}.`,
|
|
38
|
+
"A política existe no serviço e está em disco, mas não chega à sessão: o import",
|
|
39
|
+
"ausente falha em silêncio, e a sessão abre sem protocolo sem avisar ninguém.",
|
|
40
|
+
"",
|
|
41
|
+
`Acrescente esta linha ao CLAUDE.md da raiz: ${LINHA_DE_IMPORT}`,
|
|
42
|
+
"Ou rode: dd-harness init --tenant <t> --projeto <p>",
|
|
43
|
+
].join("\n"));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Os ganchos sao IMPRESSOS, nunca instalados. `.git/hooks` nao e versionado e nao e
|
|
47
|
+
* nosso: escrever la dentro sem a pessoa pedir e o mesmo tipo de invasao que
|
|
48
|
+
* sobrescrever o CLAUDE.md dela. Quem cola, decide.
|
|
49
|
+
*/
|
|
50
|
+
const GANCHOS = `
|
|
51
|
+
Opcional — dois ganchos que valem a pena:
|
|
52
|
+
|
|
53
|
+
.git/hooks/post-commit (avisa quais memórias falam do que você mudou)
|
|
54
|
+
#!/bin/sh
|
|
55
|
+
dd-harness check --commit "$(git rev-parse HEAD)" || true
|
|
56
|
+
|
|
57
|
+
.claude/settings.json (na abertura da sessão, o que espera julgamento)
|
|
58
|
+
"hooks": { "SessionStart": [{ "hooks": [{ "type": "command",
|
|
59
|
+
"command": "dd-harness status" }] }] }
|
|
60
|
+
|
|
61
|
+
Os dois terminam em sucesso mesmo com deriva: avisam, não bloqueiam.`;
|
|
62
|
+
function argumento(argv, nome) {
|
|
63
|
+
const i = argv.indexOf(`--${nome}`);
|
|
64
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
65
|
+
}
|
|
66
|
+
async function comandoInit(argv) {
|
|
67
|
+
const tenant = argumento(argv, "tenant");
|
|
68
|
+
const projeto = argumento(argv, "projeto");
|
|
69
|
+
if (!tenant || !projeto) {
|
|
70
|
+
throw new Error("uso: dd-harness init --tenant <t> --projeto <p> [--api <url>]");
|
|
71
|
+
}
|
|
72
|
+
const r = await init(process.cwd(), {
|
|
73
|
+
tenant,
|
|
74
|
+
projeto,
|
|
75
|
+
api: argumento(argv, "api"),
|
|
76
|
+
});
|
|
77
|
+
console.log(r.config === "criada" ? "criado .dd-harness.json" : "mantido .dd-harness.json");
|
|
78
|
+
console.log({
|
|
79
|
+
criado: "criado CLAUDE.md com a linha de import",
|
|
80
|
+
"linha-acrescentada": "ajustado CLAUDE.md — linha de import acrescentada ao seu",
|
|
81
|
+
"ja-tinha-a-linha": "mantido CLAUDE.md — já importava a política",
|
|
82
|
+
}[r.claudeMd]);
|
|
83
|
+
console.log("\nAgora: dd-harness login --token <token> && dd-harness sync");
|
|
84
|
+
}
|
|
85
|
+
async function comandoLogin(argv) {
|
|
86
|
+
const token = argumento(argv, "token");
|
|
87
|
+
if (!token)
|
|
88
|
+
throw new Error("uso: dd-harness login --token <token>");
|
|
89
|
+
const config = await leConfigDoRepo(process.cwd());
|
|
90
|
+
const caminho = await guardaToken(config.api, token);
|
|
91
|
+
console.log(`credencial de ${config.api} guardada em ${caminho}`);
|
|
92
|
+
}
|
|
93
|
+
async function comandoSync() {
|
|
94
|
+
const resultado = await sync(process.cwd());
|
|
95
|
+
if (resultado.tipo === "editado-a-mao") {
|
|
96
|
+
console.error([
|
|
97
|
+
"parei sem escrever nada: estes arquivos foram editados à mão.",
|
|
98
|
+
...resultado.arquivos.map((a) => ` ${a}`),
|
|
99
|
+
"",
|
|
100
|
+
"O disco é projeção do serviço, uma direção só. Leve a mudança para o serviço,",
|
|
101
|
+
"ou descarte a edição local (git checkout / apague o arquivo) e sincronize de novo.",
|
|
102
|
+
].join("\n"));
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (resultado.tipo === "sem-mudanca") {
|
|
107
|
+
console.log("nada mudou no serviço — disco já está em dia.");
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
for (const a of resultado.escritos)
|
|
111
|
+
console.log(`escrito ${a}`);
|
|
112
|
+
for (const a of resultado.removidos)
|
|
113
|
+
console.log(`removido ${a}`);
|
|
114
|
+
if (!resultado.escritos.length && !resultado.removidos.length) {
|
|
115
|
+
console.log("conteúdo novo do serviço, sem diferença em disco.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
avisaSobreOPonteiro(resultado.ponteiro);
|
|
119
|
+
}
|
|
120
|
+
async function comandoCheck(argv) {
|
|
121
|
+
const r = await check(process.cwd(), argumento(argv, "commit"));
|
|
122
|
+
if (r.medidas === 0) {
|
|
123
|
+
console.log("nenhuma âncora para medir.");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
console.log(`medidas ${r.medidas} âncora(s).`);
|
|
127
|
+
if (r.base > 0) {
|
|
128
|
+
console.log(` ${r.base} medida(s) pela primeira vez — viraram linha de base.`);
|
|
129
|
+
}
|
|
130
|
+
// Ausente e alterado nao pesam igual: o alvo ter sumido e sinal forte, e some no meio
|
|
131
|
+
// do ruido se for anunciado do mesmo jeito que "mudou".
|
|
132
|
+
if (r.ausentes.length) {
|
|
133
|
+
console.log("");
|
|
134
|
+
console.log("ALVO AUSENTE — alguém apagou ou moveu o que uma memória guarda:");
|
|
135
|
+
for (const valor of r.ausentes)
|
|
136
|
+
console.log(` ${valor}`);
|
|
137
|
+
}
|
|
138
|
+
if (r.novas > 0) {
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log(`${r.novas} deriva(s) nova(s) registrada(s) no serviço.`);
|
|
141
|
+
}
|
|
142
|
+
if (r.jaAbertas > 0) {
|
|
143
|
+
console.log(`${r.jaAbertas} já estava(m) aberta(s) — nada novo, só o carimbo.`);
|
|
144
|
+
}
|
|
145
|
+
if (!r.ausentes.length && r.novas === 0 && r.jaAbertas === 0) {
|
|
146
|
+
console.log("nenhuma deriva: o mundo ainda bate com o que as memórias dizem.");
|
|
147
|
+
}
|
|
148
|
+
// O cruzamento com o diff e outra coisa que deriva: e "voce acabou de mexer no que
|
|
149
|
+
// esta memoria guarda". Vale mesmo quando a memoria continua valendo — e o revisor
|
|
150
|
+
// que o modelo file-based dava de graca, e que centralizar tinha tirado.
|
|
151
|
+
if (r.tocadas.length) {
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log("Este commit mexeu no que estas memórias guardam:");
|
|
154
|
+
for (const t of r.tocadas) {
|
|
155
|
+
console.log(` ${t.pasta}/${t.memoria} — ${t.titulo}`);
|
|
156
|
+
console.log(` âncora: ${t.ancora}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function comandoStatus() {
|
|
161
|
+
const r = await status(process.cwd());
|
|
162
|
+
if (!r.comDeriva.length && !r.vencidas.length) {
|
|
163
|
+
console.log("dd-harness: nada esperando julgamento.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (r.comDeriva.length) {
|
|
167
|
+
const total = r.comDeriva.reduce((soma, m) => soma + m.abertas, 0);
|
|
168
|
+
console.log(`dd-harness: ${total} deriva(s) aberta(s), esperando julgamento:`);
|
|
169
|
+
for (const m of r.comDeriva) {
|
|
170
|
+
console.log(` ${m.pasta}/${m.memoria} — ${m.titulo} (${m.abertas})`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (r.vencidas.length) {
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log("Memórias com revisão vencida:");
|
|
176
|
+
for (const m of r.vencidas)
|
|
177
|
+
console.log(` ${m.pasta}/${m.memoria} — ${m.titulo}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function principal() {
|
|
181
|
+
const [comando, ...resto] = process.argv.slice(2);
|
|
182
|
+
switch (comando) {
|
|
183
|
+
case "init":
|
|
184
|
+
return comandoInit(resto);
|
|
185
|
+
case "login":
|
|
186
|
+
return comandoLogin(resto);
|
|
187
|
+
case "sync":
|
|
188
|
+
return comandoSync();
|
|
189
|
+
case "check":
|
|
190
|
+
return comandoCheck(resto);
|
|
191
|
+
case "status":
|
|
192
|
+
return comandoStatus();
|
|
193
|
+
case "--help":
|
|
194
|
+
case "-h":
|
|
195
|
+
case undefined:
|
|
196
|
+
console.log(AJUDA);
|
|
197
|
+
return;
|
|
198
|
+
default:
|
|
199
|
+
throw new Error(`comando desconhecido: ${comando}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
principal().catch((erro) => {
|
|
203
|
+
console.error(erro instanceof Error ? erro.message : String(erro));
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
});
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CAMINHO_CONFIG } from "./config.js";
|
|
4
|
+
import { LINHA_DE_IMPORT } from "./materializa.js";
|
|
5
|
+
/**
|
|
6
|
+
* Prepara um repositorio para o dd-harness.
|
|
7
|
+
*
|
|
8
|
+
* Funciona nos dois casos, e o segundo e o que importa para a ambicao de adotar projeto
|
|
9
|
+
* que ja existe: se nao ha `CLAUDE.md`, cria um com a linha de import; se **ja ha**,
|
|
10
|
+
* acrescenta a linha ao que voce escreveu, sem tocar no resto. Adotar um projeto vira
|
|
11
|
+
* uma linha, e nao um ritual de mover arquivo.
|
|
12
|
+
*/
|
|
13
|
+
const CABECALHO = `# CLAUDE.md
|
|
14
|
+
|
|
15
|
+
Este arquivo é seu: escreva aqui o que for específico deste repositório.
|
|
16
|
+
|
|
17
|
+
A linha abaixo importa a política gerenciada pelo dd-harness. **Não a remova** — sem
|
|
18
|
+
ela a sessão abre sem protocolo, e nada avisa.
|
|
19
|
+
`;
|
|
20
|
+
export async function init(raiz, dados) {
|
|
21
|
+
const caminhoConfig = join(raiz, CAMINHO_CONFIG);
|
|
22
|
+
let config = "ja-existia";
|
|
23
|
+
try {
|
|
24
|
+
await readFile(caminhoConfig, "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
const conteudo = {
|
|
28
|
+
...(dados.api ? { api: dados.api } : {}),
|
|
29
|
+
tenant: dados.tenant,
|
|
30
|
+
projeto: dados.projeto,
|
|
31
|
+
};
|
|
32
|
+
await writeFile(caminhoConfig, `${JSON.stringify(conteudo, null, 2)}\n`, "utf8");
|
|
33
|
+
config = "criada";
|
|
34
|
+
}
|
|
35
|
+
const caminhoClaude = join(raiz, "CLAUDE.md");
|
|
36
|
+
let claudeMd;
|
|
37
|
+
let atual = null;
|
|
38
|
+
try {
|
|
39
|
+
atual = await readFile(caminhoClaude, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
atual = null;
|
|
43
|
+
}
|
|
44
|
+
if (atual === null) {
|
|
45
|
+
await writeFile(caminhoClaude, `${CABECALHO}\n${LINHA_DE_IMPORT}\n`, "utf8");
|
|
46
|
+
claudeMd = "criado";
|
|
47
|
+
}
|
|
48
|
+
else if (atual.includes(LINHA_DE_IMPORT)) {
|
|
49
|
+
claudeMd = "ja-tinha-a-linha";
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Acrescenta no fim, sem reescrever nada do que ja estava la.
|
|
53
|
+
const separador = atual.endsWith("\n") ? "\n" : "\n\n";
|
|
54
|
+
await writeFile(caminhoClaude, `${atual}${separador}${LINHA_DE_IMPORT}\n`, "utf8");
|
|
55
|
+
claudeMd = "linha-acrescentada";
|
|
56
|
+
}
|
|
57
|
+
return { config, claudeMd };
|
|
58
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Do payload do contrato para arquivos em disco.
|
|
3
|
+
*
|
|
4
|
+
* Funcao pura de proposito: recebe o Brain e devolve caminho -> conteudo, sem rede e sem
|
|
5
|
+
* `fs`. E a parte que precisa de teste — o formato tem que casar com o que o
|
|
6
|
+
* `validate_brain.cjs` do molde espera (frontmatter `name` igual ao arquivo, `pasta`
|
|
7
|
+
* igual a pasta que o contem, e uma linha de indice por memoria).
|
|
8
|
+
*/
|
|
9
|
+
const AVISO = "<!-- GERADO por `dd-harness sync`. Edite no serviço, não aqui: a próxima sincronização recusa arquivo alterado à mão. -->";
|
|
10
|
+
/** Uma linha por âncora, para o leitor humano saber de que a memória depende. */
|
|
11
|
+
function secaoDeAncoras(ancoras) {
|
|
12
|
+
if (ancoras.length === 0)
|
|
13
|
+
return "";
|
|
14
|
+
const linhas = ancoras.map((a) => `- \`${a.valor}\``).join("\n");
|
|
15
|
+
return `\n## Âncoras\n\nDe que esta memória depende — mudança aqui é o que dispara aviso de deriva.\n\n${linhas}\n`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Os três filtros existem como coluna no serviço, e é isso que os torna auditáveis.
|
|
19
|
+
* Em disco eles viram seção, porque o frontmatter do molde não tem campo para texto
|
|
20
|
+
* longo e YAML multilinha é armadilha de escape.
|
|
21
|
+
*/
|
|
22
|
+
function secaoDeFiltros(m) {
|
|
23
|
+
return [
|
|
24
|
+
"\n## Os três filtros\n",
|
|
25
|
+
`**Dano:** ${m.dano}\n`,
|
|
26
|
+
`**Invisibilidade:** ${m.invisibilidade}\n`,
|
|
27
|
+
`**Externalidade:** ${m.externalidade}\n`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
export function arquivoDaMemoria(m) {
|
|
31
|
+
const frontmatter = [
|
|
32
|
+
"---",
|
|
33
|
+
`name: ${m.slug}`,
|
|
34
|
+
`description: ${m.resumo.replace(/\n/g, " ")}`,
|
|
35
|
+
`pasta: ${m.pasta}`,
|
|
36
|
+
...(m.revisar_ate ? [`revisar-ate: ${m.revisar_ate.slice(0, 10)}`] : []),
|
|
37
|
+
"---",
|
|
38
|
+
].join("\n");
|
|
39
|
+
return [
|
|
40
|
+
frontmatter,
|
|
41
|
+
"",
|
|
42
|
+
AVISO,
|
|
43
|
+
"",
|
|
44
|
+
m.corpo.trimEnd(),
|
|
45
|
+
secaoDeFiltros(m),
|
|
46
|
+
secaoDeAncoras(m.ancoras),
|
|
47
|
+
]
|
|
48
|
+
.join("\n")
|
|
49
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
50
|
+
.trimEnd()
|
|
51
|
+
.concat("\n");
|
|
52
|
+
}
|
|
53
|
+
export function arquivoDoIndice(brain) {
|
|
54
|
+
const ativas = brain.memorias.filter((m) => m.status === "ativa");
|
|
55
|
+
const historico = brain.memorias.filter((m) => m.status === "historico");
|
|
56
|
+
const linha = (m) => `- [${m.titulo}](${m.pasta}/${m.slug}.md) — ${m.resumo.replace(/\n/g, " ")}`;
|
|
57
|
+
const secoes = brain.pastas
|
|
58
|
+
.map((pasta) => {
|
|
59
|
+
const memorias = ativas.filter((m) => m.pasta === pasta.slug);
|
|
60
|
+
return [
|
|
61
|
+
`## ${pasta.slug}`,
|
|
62
|
+
`<!-- ${pasta.definicao} -->`,
|
|
63
|
+
...(memorias.length ? memorias.map(linha) : ["<!-- (vazia) -->"]),
|
|
64
|
+
].join("\n");
|
|
65
|
+
})
|
|
66
|
+
.join("\n\n");
|
|
67
|
+
const secaoHistorico = [
|
|
68
|
+
"## Histórico",
|
|
69
|
+
"<!-- Memórias substituídas por uma decisão mais nova. -->",
|
|
70
|
+
...(historico.length ? historico.map(linha) : []),
|
|
71
|
+
].join("\n");
|
|
72
|
+
return [
|
|
73
|
+
"# 🧠 Brain — Índice",
|
|
74
|
+
"",
|
|
75
|
+
AVISO,
|
|
76
|
+
"",
|
|
77
|
+
`Projeto **${brain.projeto.nome}** (\`${brain.projeto.slug}\`), espaço **${brain.tenant.nome}**.`,
|
|
78
|
+
"Uma linha por memória. As regras de gravar, encaixar e curar estão no `CLAUDE.md`.",
|
|
79
|
+
"",
|
|
80
|
+
"---",
|
|
81
|
+
"",
|
|
82
|
+
secoes,
|
|
83
|
+
"",
|
|
84
|
+
secaoHistorico,
|
|
85
|
+
"",
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
/** Tudo o que o servico gera vive aqui — e nada fora daqui e escrito pelo `sync`. */
|
|
89
|
+
export const PASTA = "dd-harness";
|
|
90
|
+
/** O que a raiz precisa conter para a politica chegar a sessao. */
|
|
91
|
+
export const LINHA_DE_IMPORT = `@${PASTA}/politica.md`;
|
|
92
|
+
/**
|
|
93
|
+
* Caminho relativo (POSIX) -> conteúdo. As chaves são o que o manifesto guarda.
|
|
94
|
+
*
|
|
95
|
+
* Tudo dentro de `dd-harness/`, inclusive a política. Na raiz fica só o `CLAUDE.md`, que
|
|
96
|
+
* é **seu**: o `sync` não o escreve, apenas confere que ele importa a política. É o que
|
|
97
|
+
* deixa conviverem a parte gerenciada e o que aquele repositório tem de próprio — e o
|
|
98
|
+
* que faz adotar um projeto existente ser uma linha, não um ritual.
|
|
99
|
+
*
|
|
100
|
+
* Artefato vazio ou ausente não entra no mapa; como o manifesto remove o que saiu do
|
|
101
|
+
* conjunto, esvaziar no serviço apaga o arquivo no próximo `sync`.
|
|
102
|
+
*/
|
|
103
|
+
export function materializa(brain, pasta = PASTA) {
|
|
104
|
+
const arquivos = new Map();
|
|
105
|
+
const artefatos = [
|
|
106
|
+
[`${pasta}/politica.md`, brain.politica],
|
|
107
|
+
[`${pasta}/BRIEFING.md`, brain.briefing],
|
|
108
|
+
];
|
|
109
|
+
for (const [caminho, conteudo] of artefatos) {
|
|
110
|
+
// Termina com quebra de linha: arquivo de texto sem ela irrita todo diff.
|
|
111
|
+
if (conteudo && conteudo.trim()) {
|
|
112
|
+
arquivos.set(caminho, conteudo.replace(/\n*$/, "\n"));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
arquivos.set(`${pasta}/brain/MEMORY.md`, arquivoDoIndice(brain));
|
|
116
|
+
for (const m of brain.memorias) {
|
|
117
|
+
arquivos.set(`${pasta}/brain/${m.pasta}/${m.slug}.md`, arquivoDaMemoria(m));
|
|
118
|
+
}
|
|
119
|
+
return arquivos;
|
|
120
|
+
}
|
package/dist/medir.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Medir o alvo de uma ancora. E a metade da deriva que so o cliente pode fazer: o
|
|
6
|
+
* servico nunca ve o repositorio.
|
|
7
|
+
*
|
|
8
|
+
* Devolve `null` quando o alvo nao existe — e o servico que decide se isso e deriva.
|
|
9
|
+
*/
|
|
10
|
+
/** Arquivo: hash do conteudo. E o que responde "mudou?" sem ambiguidade. */
|
|
11
|
+
async function hashDeArquivo(caminho) {
|
|
12
|
+
return createHash("sha256").update(await readFile(caminho)).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Diretorio: hash da LISTA de arquivos, nao do conteudo deles.
|
|
16
|
+
*
|
|
17
|
+
* Uma ancora em `supabase/migrations` quer dizer "o conjunto de migrations importa" —
|
|
18
|
+
* migration nova e o sinal. Se o hash levasse o conteudo junto, qualquer ajuste de
|
|
19
|
+
* comentario dentro de qualquer arquivo acusaria deriva, e a fila viraria ruido.
|
|
20
|
+
*/
|
|
21
|
+
async function hashDeDiretorio(caminho) {
|
|
22
|
+
const nomes = [];
|
|
23
|
+
async function anda(dir, prefixo) {
|
|
24
|
+
for (const entrada of await readdir(dir, { withFileTypes: true })) {
|
|
25
|
+
const rel = prefixo ? posix.join(prefixo, entrada.name) : entrada.name;
|
|
26
|
+
if (entrada.isDirectory())
|
|
27
|
+
await anda(join(dir, entrada.name), rel);
|
|
28
|
+
else
|
|
29
|
+
nomes.push(rel);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await anda(caminho, "");
|
|
33
|
+
return createHash("sha256").update(nomes.sort().join("\n")).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
export async function mede(raiz, valor) {
|
|
36
|
+
const caminho = join(raiz, valor);
|
|
37
|
+
try {
|
|
38
|
+
const info = await stat(caminho);
|
|
39
|
+
return info.isDirectory()
|
|
40
|
+
? await hashDeDiretorio(caminho)
|
|
41
|
+
: await hashDeArquivo(caminho);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { gravaManifesto, hashDe, leConfigDoRepo, leManifesto, leToken, } from "./config.js";
|
|
4
|
+
import { LINHA_DE_IMPORT, materializa } from "./materializa.js";
|
|
5
|
+
async function leSeExistir(caminho) {
|
|
6
|
+
try {
|
|
7
|
+
return await readFile(caminho, "utf8");
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Estado do que este comando escreveu, contra o hash guardado.
|
|
15
|
+
*
|
|
16
|
+
* Duas situacoes diferentes, e confundi-las custa caro: hash diferente e **edicao a
|
|
17
|
+
* mao**, e o comando tem que parar sem escrever; arquivo **ausente** nao e edicao — e
|
|
18
|
+
* trabalho a refazer, e o comando tem que reescrever.
|
|
19
|
+
*/
|
|
20
|
+
export async function confereDisco(raiz, arquivos) {
|
|
21
|
+
const editados = [];
|
|
22
|
+
const faltando = [];
|
|
23
|
+
for (const [relativo, hashGuardado] of Object.entries(arquivos)) {
|
|
24
|
+
const atual = await leSeExistir(join(raiz, relativo));
|
|
25
|
+
if (atual === null)
|
|
26
|
+
faltando.push(relativo);
|
|
27
|
+
else if (hashDe(atual) !== hashGuardado)
|
|
28
|
+
editados.push(relativo);
|
|
29
|
+
}
|
|
30
|
+
return { editados: editados.sort(), faltando: faltando.sort() };
|
|
31
|
+
}
|
|
32
|
+
async function buscaBrain(config, token, etag) {
|
|
33
|
+
const url = `${config.api}/api/v1/artefatos?tenant=${encodeURIComponent(config.tenant)}&projeto=${encodeURIComponent(config.projeto)}`;
|
|
34
|
+
const resposta = await fetch(url, {
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
...(etag ? { "If-None-Match": etag } : {}),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
if (resposta.status === 304)
|
|
41
|
+
return { naoMudou: true };
|
|
42
|
+
if (resposta.status === 401) {
|
|
43
|
+
throw new Error("token recusado. Rode `dd-harness login --token <token>`.");
|
|
44
|
+
}
|
|
45
|
+
if (resposta.status === 404) {
|
|
46
|
+
throw new Error(`projeto ${config.tenant}/${config.projeto} não encontrado — ou não é seu.`);
|
|
47
|
+
}
|
|
48
|
+
if (!resposta.ok) {
|
|
49
|
+
throw new Error(`a API respondeu ${resposta.status}.`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
naoMudou: false,
|
|
53
|
+
brain: (await resposta.json()),
|
|
54
|
+
etag: resposta.headers.get("etag"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* O `CLAUDE.md` da raiz existe e importa a politica?
|
|
59
|
+
*
|
|
60
|
+
* Nao ha politica no servico? Entao nao ha o que apontar, e a ausencia do ponteiro nao e
|
|
61
|
+
* problema — avisar aqui seria ruido que ensina a ignorar aviso.
|
|
62
|
+
*/
|
|
63
|
+
export async function confereOPonteiro(raiz, temPolitica) {
|
|
64
|
+
if (!temPolitica)
|
|
65
|
+
return "sem-politica";
|
|
66
|
+
const claudeMd = await leSeExistir(join(raiz, "CLAUDE.md"));
|
|
67
|
+
if (claudeMd === null)
|
|
68
|
+
return "sem-claude-md";
|
|
69
|
+
return claudeMd.includes(LINHA_DE_IMPORT) ? "ok" : "sem-a-linha";
|
|
70
|
+
}
|
|
71
|
+
export async function sync(raiz) {
|
|
72
|
+
const config = await leConfigDoRepo(raiz);
|
|
73
|
+
const token = await leToken(config.api);
|
|
74
|
+
if (!token) {
|
|
75
|
+
throw new Error(`sem credencial para ${config.api}. Rode \`dd-harness login --token <token>\`.`);
|
|
76
|
+
}
|
|
77
|
+
const manifesto = await leManifesto(raiz);
|
|
78
|
+
// A guarda vem ANTES da rede, e nao depois do 304, porque a divergencia pode estar
|
|
79
|
+
// deste lado: servico igual e disco editado a mao e o caso que mais interessa avisar,
|
|
80
|
+
// e um 304 que respondesse "nada mudou" o esconderia.
|
|
81
|
+
const { editados, faltando } = await confereDisco(raiz, manifesto.arquivos);
|
|
82
|
+
const politicaNoManifesto = Object.keys(manifesto.arquivos).some((a) => a.endsWith(`${config.pasta}/politica.md`));
|
|
83
|
+
if (editados.length) {
|
|
84
|
+
return {
|
|
85
|
+
tipo: "editado-a-mao",
|
|
86
|
+
arquivos: editados,
|
|
87
|
+
ponteiro: await confereOPonteiro(raiz, politicaNoManifesto),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// `If-None-Match` so quando o disco esta completo. Faltando arquivo, um 304 diria
|
|
91
|
+
// "nada a fazer" e o comando nunca conseguiria reescrever o que foi apagado — a
|
|
92
|
+
// sincronizacao ficaria incapaz de consertar o disco, que e metade do trabalho dela.
|
|
93
|
+
const resposta = await buscaBrain(config, token, faltando.length ? null : manifesto.etag);
|
|
94
|
+
if (resposta.naoMudou) {
|
|
95
|
+
// Confere mesmo sem mudanca no servico: quem apaga a linha de import e quem mexe no
|
|
96
|
+
// repositorio, e um 304 nao sabe nada sobre isso.
|
|
97
|
+
return {
|
|
98
|
+
tipo: "sem-mudanca",
|
|
99
|
+
ponteiro: await confereOPonteiro(raiz, politicaNoManifesto),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const desejado = materializa(resposta.brain, config.pasta);
|
|
103
|
+
// Arquivo que o servico quer escrever e que existe em disco sem estar no manifesto e
|
|
104
|
+
// de outra origem — escrito a mao antes do primeiro sync. Tambem nao e nosso.
|
|
105
|
+
const alheios = [];
|
|
106
|
+
for (const relativo of desejado.keys()) {
|
|
107
|
+
if (manifesto.arquivos[relativo])
|
|
108
|
+
continue;
|
|
109
|
+
if ((await leSeExistir(join(raiz, relativo))) !== null)
|
|
110
|
+
alheios.push(relativo);
|
|
111
|
+
}
|
|
112
|
+
if (alheios.length) {
|
|
113
|
+
return {
|
|
114
|
+
tipo: "editado-a-mao",
|
|
115
|
+
arquivos: alheios.sort(),
|
|
116
|
+
ponteiro: await confereOPonteiro(raiz, desejado.has(`${config.pasta}/politica.md`)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const escritos = [];
|
|
120
|
+
for (const [relativo, conteudo] of desejado) {
|
|
121
|
+
const caminho = join(raiz, relativo);
|
|
122
|
+
const atual = await leSeExistir(caminho);
|
|
123
|
+
if (atual === conteudo)
|
|
124
|
+
continue;
|
|
125
|
+
await mkdir(dirname(caminho), { recursive: true });
|
|
126
|
+
await writeFile(caminho, conteudo, "utf8");
|
|
127
|
+
escritos.push(relativo);
|
|
128
|
+
}
|
|
129
|
+
// Memoria que saiu do servico tem que sair do disco, senao o repo guarda para sempre
|
|
130
|
+
// uma memoria que ninguem mais vai atualizar. So remove o que era nosso e nao mudou.
|
|
131
|
+
const removidos = [];
|
|
132
|
+
for (const relativo of Object.keys(manifesto.arquivos)) {
|
|
133
|
+
if (desejado.has(relativo))
|
|
134
|
+
continue;
|
|
135
|
+
await rm(join(raiz, relativo), { force: true });
|
|
136
|
+
removidos.push(relativo);
|
|
137
|
+
}
|
|
138
|
+
const novo = {
|
|
139
|
+
etag: resposta.etag,
|
|
140
|
+
arquivos: Object.fromEntries([...desejado].map(([relativo, conteudo]) => [relativo, hashDe(conteudo)])),
|
|
141
|
+
};
|
|
142
|
+
await gravaManifesto(raiz, novo);
|
|
143
|
+
return {
|
|
144
|
+
tipo: "sincronizado",
|
|
145
|
+
escritos: escritos.sort(),
|
|
146
|
+
removidos: removidos.sort(),
|
|
147
|
+
ponteiro: await confereOPonteiro(raiz, desejado.has(`${config.pasta}/politica.md`)),
|
|
148
|
+
};
|
|
149
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dd-harness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Materializa politica, briefing e Brain do dd-harness no repositorio. Sem dependencia: fetch, crypto e fs sao do Node.",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"author": "Diego Dias",
|
|
8
|
+
"keywords": ["claude-code", "ai-agents", "memory", "brain", "cli"],
|
|
9
|
+
"homepage": "https://dd-harness.vercel.app",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/diegodias93/dd-harness-online.git",
|
|
13
|
+
"directory": "packages/cli"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"dd-harness": "dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist"],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc -p . --noEmit",
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
}
|
|
27
|
+
}
|