dd-harness-mcp 0.11.0 → 0.13.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/dist/cli/src/cinto.js +178 -0
- package/dist/cli/src/diff.js +103 -0
- package/dist/cli/src/escreve-config.js +32 -13
- package/dist/cli/src/index.js +62 -4
- package/dist/cli/src/politica.js +2 -2
- package/dist/cli/src/roadmap.js +21 -0
- package/dist/mcp/src/index.js +37 -4
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { memoriasNoConteudo } from "./diff.js";
|
|
4
|
+
/**
|
|
5
|
+
* O cinto de seguranca: a memoria chega ANTES da edicao, sem ninguem ter pedido.
|
|
6
|
+
*
|
|
7
|
+
* O Brain ate aqui e PULL — o agente precisa lembrar de chamar `buscar_memoria`. Isso
|
|
8
|
+
* falha exatamente onde mais custa: o agente nao busca quando *acha que sabe*. Ninguem
|
|
9
|
+
* consulta memoria antes de trocar um `SECURITY DEFINER` por `INVOKER` para silenciar um
|
|
10
|
+
* advisor; a tarefa parece trivial, e a melhor pratica da internet diz para fazer isso.
|
|
11
|
+
* A memoria existe justamente porque, neste projeto, isso quebrou a RLS inteira.
|
|
12
|
+
*
|
|
13
|
+
* O PUSH inverte: o hook `PreToolUse` cruza o arquivo que o agente esta editando contra
|
|
14
|
+
* as ancoras e injeta a memoria como `additionalContext`. AVISA, NUNCA BLOQUEIA — mesma
|
|
15
|
+
* regra da deriva e dos ganchos. Bloquear antes de o mecanismo ter reputacao ensina o
|
|
16
|
+
* reflexo do `--no-verify`, e ai nao ha cinto nenhum.
|
|
17
|
+
*
|
|
18
|
+
* Tudo local, de proposito. O hook e SINCRONO: cada `Edit` espera ele terminar. Medido
|
|
19
|
+
* contra producao, a rede custa ~250ms quente e ate 1,9s no cold start — por edicao. O
|
|
20
|
+
* cruzamento local custa ~100ms, quase tudo boot do Node. Por isso o cache: o payload do
|
|
21
|
+
* `SessionStart` ja traz ancoras, resumo e corpo, entao ele sai de graca, sem endpoint
|
|
22
|
+
* novo e sem uma segunda chamada.
|
|
23
|
+
*/
|
|
24
|
+
/** Onde o cache vive. Fora do repositorio: nada do dd-harness volta a morar em disco versionado. */
|
|
25
|
+
export function caminhoDoCache(raiz) {
|
|
26
|
+
return join(raiz, ".claude", "dd-harness-ancoras.json");
|
|
27
|
+
}
|
|
28
|
+
/** Guarda as ancoras do Brain para o hook consultar sem rede. Falha em silencio: cache e otimizacao, nao contrato. */
|
|
29
|
+
export async function guardaCache(raiz, brain) {
|
|
30
|
+
const cache = {
|
|
31
|
+
gravado_em: new Date().toISOString(),
|
|
32
|
+
memorias: brain.memorias
|
|
33
|
+
.filter((m) => m.status === "ativa" && m.ancoras.length > 0)
|
|
34
|
+
.map(({ pasta, slug, titulo, resumo, status, ancoras }) => ({
|
|
35
|
+
pasta,
|
|
36
|
+
slug,
|
|
37
|
+
titulo,
|
|
38
|
+
resumo,
|
|
39
|
+
status,
|
|
40
|
+
ancoras,
|
|
41
|
+
})),
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const caminho = caminhoDoCache(raiz);
|
|
45
|
+
await mkdir(dirname(caminho), { recursive: true });
|
|
46
|
+
await writeFile(caminho, `${JSON.stringify(cache)}\n`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Sem cache o hook cala, e o PULL continua funcionando. Nao vale falhar a sessao.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function leCache(raiz) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(await readFile(caminhoDoCache(raiz), "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Um alerta por memoria por sessao. Sem isto, o agente que edita o mesmo arquivo dez
|
|
62
|
+
* vezes ate o teste passar recebe o mesmo texto dez vezes — e aviso repetido nao reforca,
|
|
63
|
+
* anestesia: na terceira repeticao ele vira ruido de fundo, e a memoria perde justamente
|
|
64
|
+
* a autoridade que o PUSH existe para dar.
|
|
65
|
+
*
|
|
66
|
+
* A chave e a MEMORIA, nao o arquivo: duas memorias no mesmo arquivo sao dois avisos
|
|
67
|
+
* diferentes, e calar a segunda porque a primeira ja falou esconderia a que importava.
|
|
68
|
+
*
|
|
69
|
+
* Por sessao, nao para sempre: sessao nova e contexto novo, e o agente que abre amanha
|
|
70
|
+
* nao viu o aviso de hoje.
|
|
71
|
+
*/
|
|
72
|
+
async function jaAvisou(raiz, sessao, enderecos) {
|
|
73
|
+
const caminho = join(raiz, ".claude", `dd-harness-avisos-${sessao.replace(/[^\w-]/g, "")}.json`);
|
|
74
|
+
let vistos = [];
|
|
75
|
+
try {
|
|
76
|
+
vistos = JSON.parse(await readFile(caminho, "utf8"));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Primeira edicao da sessao: nada visto ainda.
|
|
80
|
+
}
|
|
81
|
+
const novos = enderecos.filter((e) => !vistos.includes(e));
|
|
82
|
+
if (novos.length === 0)
|
|
83
|
+
return [];
|
|
84
|
+
try {
|
|
85
|
+
await mkdir(dirname(caminho), { recursive: true });
|
|
86
|
+
await writeFile(caminho, JSON.stringify([...vistos, ...novos]), "utf8");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Sem registro, o alerta repete — degrada para barulhento, nunca para calado.
|
|
90
|
+
}
|
|
91
|
+
return novos;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* O caminho do arquivo relativo a raiz, no formato das ancoras (barras normais).
|
|
95
|
+
*
|
|
96
|
+
* `null` quando a edicao e fora do repositorio: `..` no relativo. Ancora nunca aponta
|
|
97
|
+
* para fora — o CHECK do banco recusa — entao nao ha o que cruzar, e avisar seria ruido.
|
|
98
|
+
*/
|
|
99
|
+
export function caminhoRelativo(raiz, arquivo) {
|
|
100
|
+
const rel = relative(resolve(raiz), resolve(arquivo));
|
|
101
|
+
if (!rel || rel.startsWith(".."))
|
|
102
|
+
return null;
|
|
103
|
+
return rel.split(sep).join("/");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A memoria que o agente precisa ver ANTES de escrever — ou `""` quando nao ha nenhuma.
|
|
107
|
+
*
|
|
108
|
+
* Vazio e o caso comum e tem que sair barato: a maioria das edicoes nao toca ancora
|
|
109
|
+
* alguma, e o silencio ai nao e falta de aviso, e a ausencia de motivo para avisar.
|
|
110
|
+
*/
|
|
111
|
+
export function alerta(tocadas, resumos) {
|
|
112
|
+
if (tocadas.length === 0)
|
|
113
|
+
return "";
|
|
114
|
+
const linhas = [
|
|
115
|
+
"ALERTA DE CICATRIZ (dd-harness) — este arquivo tem memória do projeto.",
|
|
116
|
+
"",
|
|
117
|
+
];
|
|
118
|
+
for (const t of tocadas) {
|
|
119
|
+
const endereco = `${t.pasta}/${t.memoria}`;
|
|
120
|
+
linhas.push(`## ${t.titulo}`, `\`${endereco}\` — âncora: \`${t.ancora}\``, "");
|
|
121
|
+
const resumo = resumos.get(endereco);
|
|
122
|
+
if (resumo)
|
|
123
|
+
linhas.push(resumo, "");
|
|
124
|
+
}
|
|
125
|
+
linhas.push("Isto não bloqueia nada: siga se a mudança for deliberada. Mas se ela contradiz a " +
|
|
126
|
+
"memória acima, pare e diga isso ao usuário antes de escrever — é para este " +
|
|
127
|
+
"momento que a memória foi gravada. `ler_memoria` traz o texto inteiro, com o porquê.");
|
|
128
|
+
return linhas.join("\n");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* O hook inteiro: le a entrada, cruza, devolve o JSON que o Claude Code espera.
|
|
132
|
+
*
|
|
133
|
+
* SEMPRE `permissionDecision: "allow"` — provado no runtime que `additionalContext`
|
|
134
|
+
* chega ao agente junto com `allow`, sem bloquear a edicao. Esse par e o que torna o
|
|
135
|
+
* "avisa, nao impede" possivel aqui; com `deny` o aviso existiria so as custas de
|
|
136
|
+
* recusar a edicao.
|
|
137
|
+
*/
|
|
138
|
+
export async function decideDoHook(entrada) {
|
|
139
|
+
const permitir = JSON.stringify({
|
|
140
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
|
141
|
+
});
|
|
142
|
+
const arquivo = entrada.tool_input?.file_path;
|
|
143
|
+
const raiz = entrada.cwd;
|
|
144
|
+
if (!arquivo || !raiz)
|
|
145
|
+
return permitir;
|
|
146
|
+
const relativo = caminhoRelativo(raiz, arquivo);
|
|
147
|
+
if (!relativo)
|
|
148
|
+
return permitir;
|
|
149
|
+
const cache = await leCache(raiz);
|
|
150
|
+
if (!cache || cache.memorias.length === 0)
|
|
151
|
+
return permitir;
|
|
152
|
+
// `Edit` manda o trecho trocado; `Write` manda o arquivo inteiro. Nos dois casos o que
|
|
153
|
+
// interessa e o texto que existe agora e o que vai existir — e a ancora de trecho casa
|
|
154
|
+
// com qualquer um dos dois lados.
|
|
155
|
+
const { old_string, new_string, content } = entrada.tool_input ?? {};
|
|
156
|
+
const antes = old_string ?? (await readFile(arquivo, "utf8").catch(() => null));
|
|
157
|
+
const depois = new_string ?? content ?? null;
|
|
158
|
+
const brain = { memorias: cache.memorias };
|
|
159
|
+
const tocadas = memoriasNoConteudo(brain, relativo, antes, depois);
|
|
160
|
+
if (tocadas.length === 0)
|
|
161
|
+
return permitir;
|
|
162
|
+
// Sem `session_id` nao ha como separar uma sessao da outra; avisa sempre, que e o lado
|
|
163
|
+
// seguro de errar: repetir cansa, calar deixa passar.
|
|
164
|
+
const inedito = entrada.session_id
|
|
165
|
+
? await jaAvisou(raiz, entrada.session_id, tocadas.map((t) => `${t.pasta}/${t.memoria}`))
|
|
166
|
+
: tocadas.map((t) => `${t.pasta}/${t.memoria}`);
|
|
167
|
+
const novas = tocadas.filter((t) => inedito.includes(`${t.pasta}/${t.memoria}`));
|
|
168
|
+
if (novas.length === 0)
|
|
169
|
+
return permitir;
|
|
170
|
+
const resumos = new Map(cache.memorias.map((m) => [`${m.pasta}/${m.slug}`, m.resumo]));
|
|
171
|
+
return JSON.stringify({
|
|
172
|
+
hookSpecificOutput: {
|
|
173
|
+
hookEventName: "PreToolUse",
|
|
174
|
+
permissionDecision: "allow",
|
|
175
|
+
additionalContext: alerta(novas, resumos),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
package/dist/cli/src/diff.js
CHANGED
|
@@ -138,3 +138,106 @@ export function memoriasTocadas(brain, caminhos) {
|
|
|
138
138
|
}
|
|
139
139
|
return tocadas;
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* O que a arvore de trabalho tem de modificado AGORA — sem commit nenhum.
|
|
143
|
+
*
|
|
144
|
+
* `caminhosDoCommit` responde "o que aquele commit mudou"; esta responde "o que esta
|
|
145
|
+
* mudado neste instante". E a diferenca entre o code review (depois) e a sessao em
|
|
146
|
+
* andamento (durante), e e o que permite propor ancora no momento em que a memoria nasce:
|
|
147
|
+
* quem grava uma memoria acabou de mexer no que ela descreve.
|
|
148
|
+
*
|
|
149
|
+
* Inclui nao rastreados (`--others`): arquivo novo e exatamente onde a decisao recem
|
|
150
|
+
* tomada costuma morar, e exclui-lo deixaria de fora o caso mais comum.
|
|
151
|
+
*
|
|
152
|
+
* Lista vazia quando nao ha git — sem lancar. Propor ancora e um extra; um projeto sem
|
|
153
|
+
* repositorio continua gravando memoria normalmente, so sem a sugestao.
|
|
154
|
+
*/
|
|
155
|
+
export async function caminhosDaArvore(raiz) {
|
|
156
|
+
try {
|
|
157
|
+
const { stdout } = await roda("git", ["status", "--porcelain=v1", "--untracked-files=all", "--no-renames"], { cwd: raiz });
|
|
158
|
+
const caminhos = [];
|
|
159
|
+
for (const linha of stdout.split(/\r?\n/)) {
|
|
160
|
+
if (!linha.trim())
|
|
161
|
+
continue;
|
|
162
|
+
// `XY caminho` — os dois primeiros caracteres sao o estado no indice e na arvore.
|
|
163
|
+
const caminho = linha.slice(3).trim();
|
|
164
|
+
if (caminho)
|
|
165
|
+
caminhos.push(descitado(caminho));
|
|
166
|
+
}
|
|
167
|
+
return caminhos;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* O git cita caminhos com caractere especial (`"src/caf\303\251.ts"`). Sem desfazer isso,
|
|
175
|
+
* a ancora proposta nasce com aspas no valor e nunca casa com arquivo nenhum.
|
|
176
|
+
*/
|
|
177
|
+
function descitado(caminho) {
|
|
178
|
+
if (!caminho.startsWith('"') || !caminho.endsWith('"'))
|
|
179
|
+
return caminho;
|
|
180
|
+
const cru = caminho.slice(1, -1);
|
|
181
|
+
const bytes = [];
|
|
182
|
+
for (let i = 0; i < cru.length; i++) {
|
|
183
|
+
if (cru[i] === "\\" && /[0-7]/.test(cru[i + 1] ?? "")) {
|
|
184
|
+
bytes.push(parseInt(cru.slice(i + 1, i + 4), 8));
|
|
185
|
+
i += 3;
|
|
186
|
+
}
|
|
187
|
+
else if (cru[i] === "\\") {
|
|
188
|
+
bytes.push(cru.charCodeAt(++i));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
bytes.push(...new TextEncoder().encode(cru[i]));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Cruza ancoras com o CONTEUDO de uma edicao, nao so com o nome do arquivo.
|
|
198
|
+
*
|
|
199
|
+
* `memoriasTocadas` casa por arquivo porque e tudo que o `git diff-tree` permite — ele
|
|
200
|
+
* devolve caminho puro, nunca o trecho. Aqui e diferente: quem chama tem o texto que vai
|
|
201
|
+
* ser escrito, entao a ancora de trecho pode ser verificada de verdade.
|
|
202
|
+
*
|
|
203
|
+
* A diferenca importa porque e o que separa aviso de ruido. Medido neste projeto: duas
|
|
204
|
+
* memorias ancoradas no mesmo `config/limites.json` eram as duas sinalizadas quando so
|
|
205
|
+
* uma das chaves mudava. Numa interrupcao nao solicitada — que e o que o interceptador
|
|
206
|
+
* faz — errar assim ensina a ignorar o aviso, e um aviso ignorado e pior que nenhum.
|
|
207
|
+
*
|
|
208
|
+
* A regra, por tipo de ancora:
|
|
209
|
+
* - `arquivo` -> casa sempre que o arquivo casa. A memoria fala do todo.
|
|
210
|
+
* - `arquivo#trecho` -> casa so se o trecho aparecer no texto ANTES ou DEPOIS da edicao.
|
|
211
|
+
* Antes: a edicao esta mexendo onde a memoria guarda. Depois: esta mexendo para la.
|
|
212
|
+
*
|
|
213
|
+
* `antes` ausente (arquivo novo, ou `Write` que nao le o original) cai so no `depois` —
|
|
214
|
+
* sem inventar casamento por falta de dado.
|
|
215
|
+
*/
|
|
216
|
+
export function memoriasNoConteudo(brain, caminho, antes, depois) {
|
|
217
|
+
const tocadas = [];
|
|
218
|
+
for (const memoria of brain.memorias) {
|
|
219
|
+
if (memoria.status !== "ativa")
|
|
220
|
+
continue;
|
|
221
|
+
for (const ancora of memoria.ancoras) {
|
|
222
|
+
const corte = ancora.valor.indexOf("#");
|
|
223
|
+
const alvo = corte === -1 ? ancora.valor : ancora.valor.slice(0, corte);
|
|
224
|
+
const arquivoCasa = caminho === alvo || caminho.startsWith(`${alvo}/`);
|
|
225
|
+
if (!arquivoCasa)
|
|
226
|
+
continue;
|
|
227
|
+
if (corte !== -1) {
|
|
228
|
+
const trecho = ancora.valor.slice(corte + 1);
|
|
229
|
+
const apareceAntes = antes?.includes(trecho) ?? false;
|
|
230
|
+
const apareceDepois = depois?.includes(trecho) ?? false;
|
|
231
|
+
if (!apareceAntes && !apareceDepois)
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
tocadas.push({
|
|
235
|
+
pasta: memoria.pasta,
|
|
236
|
+
memoria: memoria.slug,
|
|
237
|
+
titulo: memoria.titulo,
|
|
238
|
+
ancora: ancora.valor,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return tocadas;
|
|
243
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
const COMANDO_DO_HOOK = "dd-harness politica --hook";
|
|
4
|
+
const COMANDO_DO_CINTO = "dd-harness cinto";
|
|
4
5
|
/**
|
|
5
6
|
* Acrescenta o hook do dd-harness a `.claude/settings.json`, sem tocar em outros hooks.
|
|
6
7
|
*
|
|
@@ -23,19 +24,33 @@ export async function escreveHook(raiz) {
|
|
|
23
24
|
}
|
|
24
25
|
const hooks = (doc.hooks ??= {});
|
|
25
26
|
const sessionStart = (hooks.SessionStart ??= []);
|
|
26
|
-
const
|
|
27
|
-
|
|
27
|
+
const preToolUse = (hooks.PreToolUse ??= []);
|
|
28
|
+
const temSessionStart = JSON.stringify(sessionStart).includes(COMANDO_DO_HOOK);
|
|
29
|
+
// O interceptador pre-voo e checado SEPARADAMENTE do SessionStart, e nao junto: quem
|
|
30
|
+
// instalou o dd-harness antes desta versao ja tem o SessionStart e nao tem o cinto. Um
|
|
31
|
+
// `return` antecipado por "ja tinha" deixaria esses repositorios sem o cinto para
|
|
32
|
+
// sempre, calados — que e a classe de defeito que este projeto mais persegue.
|
|
33
|
+
const temCinto = JSON.stringify(preToolUse).includes(COMANDO_DO_CINTO);
|
|
34
|
+
if (temSessionStart && temCinto)
|
|
28
35
|
return { ok: true, estado: "ja-tinha" };
|
|
29
|
-
const eraVazio = sessionStart.length === 0;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const eraVazio = sessionStart.length === 0 && preToolUse.length === 0;
|
|
37
|
+
if (!temSessionStart) {
|
|
38
|
+
sessionStart.push({
|
|
39
|
+
hooks: [
|
|
40
|
+
{
|
|
41
|
+
type: "command",
|
|
42
|
+
command: COMANDO_DO_HOOK,
|
|
43
|
+
statusMessage: "Carregando a política do dd-harness...",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (!temCinto) {
|
|
49
|
+
preToolUse.push({
|
|
50
|
+
matcher: "Edit|Write|MultiEdit",
|
|
51
|
+
hooks: [{ type: "command", command: COMANDO_DO_CINTO }],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
39
54
|
await mkdir(dirname(caminho), { recursive: true });
|
|
40
55
|
await writeFile(caminho, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
41
56
|
return { ok: true, estado: eraVazio ? "criado" : "acrescentado" };
|
|
@@ -107,4 +122,8 @@ de ler código, responder ou planejar.
|
|
|
107
122
|
|
|
108
123
|
- Se a ferramenta não existir, o servidor MCP do dd-harness não está declarado:
|
|
109
124
|
avise o usuário e **não modifique nada** até ele resolver.
|
|
110
|
-
- Se ela devolver vazio, este projeto nunca foi briefado — diga isso ao usuário
|
|
125
|
+
- Se ela devolver vazio, este projeto nunca foi briefado — diga isso ao usuário.
|
|
126
|
+
|
|
127
|
+
Depois, \`ler_roadmap\`: se houver uma fase **Agora**, é dela que saem os passos
|
|
128
|
+
desta sessão. Lista vazia significa que este projeto não usa roadmap, e isso é
|
|
129
|
+
válido — não crie fase sem o usuário pedir.`;
|
package/dist/cli/src/index.js
CHANGED
|
@@ -19,7 +19,8 @@ import { grava } from "./gravar.js";
|
|
|
19
19
|
import { init, SUGESTAO_AGENTS, SUGESTAO_HOOK, SUGESTAO_MCP } from "./init.js";
|
|
20
20
|
import { pergunta, escolha, fechaPerguntas } from "./pergunta.js";
|
|
21
21
|
import { buscaPolitica } from "./politica.js";
|
|
22
|
-
import { blocoDeSessao, criaFase, editaFase, formataChangelog, formataRoadmap, leFases, } from "./roadmap.js";
|
|
22
|
+
import { avisoDeOrdem, blocoDeSessao, criaFase, editaFase, formataChangelog, formataRoadmap, leFases, } from "./roadmap.js";
|
|
23
|
+
import { decideDoHook, guardaCache } from "./cinto.js";
|
|
23
24
|
import { busca } from "./buscar.js";
|
|
24
25
|
import { arquiva, edita, le } from "./curar.js";
|
|
25
26
|
import { criaPasta } from "./pasta.js";
|
|
@@ -62,6 +63,9 @@ const AJUDA = `dd-harness — a política e o Brain do projeto, no serviço
|
|
|
62
63
|
1 = não consegui buscar
|
|
63
64
|
--hook: fala o protocolo do SessionStart do
|
|
64
65
|
Claude Code, para pôr a política no contexto
|
|
66
|
+
dd-harness cinto o interceptador pré-voo: lê a edição no stdin
|
|
67
|
+
e devolve a memória que fala daquele trecho.
|
|
68
|
+
Quem chama é o hook PreToolUse, não você
|
|
65
69
|
dd-harness roadmap as fases abertas: a atual inteira, as próximas
|
|
66
70
|
por título (opcional — projeto sem fase não tem)
|
|
67
71
|
dd-harness changelog [--versao <v>]
|
|
@@ -155,7 +159,7 @@ async function comandoFase(argv) {
|
|
|
155
159
|
throw new Error("fase editar exige o slug da fase.");
|
|
156
160
|
const ordemBruta = argumento(opcoes, "ordem");
|
|
157
161
|
const status = statusDoArgv(opcoes);
|
|
158
|
-
const { fase } = await editaFase(process.cwd(), slug, {
|
|
162
|
+
const { fase, atras_de } = await editaFase(process.cwd(), slug, {
|
|
159
163
|
titulo: argumento(opcoes, "titulo"),
|
|
160
164
|
conteudo: await conteudoDoArgv(opcoes),
|
|
161
165
|
versao: opcoes.includes("--sem-versao") ? null : argumento(opcoes, "versao"),
|
|
@@ -165,7 +169,8 @@ async function comandoFase(argv) {
|
|
|
165
169
|
console.log(`Fase ${fase.slug} atualizada — ${fase.status}` +
|
|
166
170
|
(fase.versao ? `, versão ${fase.versao}` : "") +
|
|
167
171
|
(status === "concluida" ? " (saiu do roadmap, entrou no changelog)" : "") +
|
|
168
|
-
"."
|
|
172
|
+
"." +
|
|
173
|
+
avisoDeOrdem(fase, atras_de, ordemBruta !== undefined));
|
|
169
174
|
return;
|
|
170
175
|
}
|
|
171
176
|
throw new Error('uso: dd-harness fase criar --titulo "<t>" [...] | dd-harness fase editar <slug> [...]');
|
|
@@ -647,6 +652,34 @@ async function comandoCheck(argv) {
|
|
|
647
652
|
* um "falhou" generico colapsaria: projeto novo (que precisa de briefing) de politica
|
|
648
653
|
* inalcancavel (que precisa PARAR a sessao). Mudar estes numeros quebra o hook.
|
|
649
654
|
*/
|
|
655
|
+
/**
|
|
656
|
+
* `dd-harness cinto` — o interceptador pre-voo, chamado pelo hook `PreToolUse`.
|
|
657
|
+
*
|
|
658
|
+
* Le a chamada de ferramenta no stdin e devolve o JSON que o Claude Code espera. SEMPRE
|
|
659
|
+
* permite: o alerta viaja em `additionalContext`, que chega ao agente antes da edicao
|
|
660
|
+
* sem recusa-la (provado no runtime, nao inferido da documentacao).
|
|
661
|
+
*
|
|
662
|
+
* Nunca falha o processo. Este comando roda ANTES de cada `Edit`, e um hook que quebra
|
|
663
|
+
* bloquearia o trabalho por causa de um aviso — exatamente o contrario do que ele existe
|
|
664
|
+
* para fazer. Qualquer erro vira "permitir e calar".
|
|
665
|
+
*/
|
|
666
|
+
async function comandoCinto() {
|
|
667
|
+
const permitir = JSON.stringify({
|
|
668
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
|
669
|
+
});
|
|
670
|
+
try {
|
|
671
|
+
const pedacos = [];
|
|
672
|
+
for await (const p of process.stdin)
|
|
673
|
+
pedacos.push(p);
|
|
674
|
+
const cru = Buffer.concat(pedacos).toString("utf8").trim();
|
|
675
|
+
if (!cru)
|
|
676
|
+
return console.log(permitir);
|
|
677
|
+
console.log(await decideDoHook(JSON.parse(cru)));
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
console.log(permitir);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
650
683
|
async function comandoPolitica(argv) {
|
|
651
684
|
const r = await buscaPolitica(process.cwd());
|
|
652
685
|
// `--hook`: fala o protocolo do SessionStart do Claude Code, que injeta
|
|
@@ -658,6 +691,11 @@ async function comandoPolitica(argv) {
|
|
|
658
691
|
// hospedado, e memoria sem vetor some da busca sem nada denunciar. Sobe so quando ha
|
|
659
692
|
// fila de verdade — o hook roda ate nas sessoes que so leem codigo.
|
|
660
693
|
const worker = await subiuOWorker(process.cwd(), r.esperandoIndexacao ?? 0);
|
|
694
|
+
// O cache que o interceptador pre-voo le a cada edicao. Aqui e o unico lugar onde ele
|
|
695
|
+
// pode ser escrito sem custo: a requisicao ja foi paga, e as ancoras vieram junto.
|
|
696
|
+
if (r.memorias) {
|
|
697
|
+
await guardaCache(process.cwd(), { memorias: r.memorias });
|
|
698
|
+
}
|
|
661
699
|
console.log(JSON.stringify({ hookSpecificOutput: contextoDaSessao(r, worker) }));
|
|
662
700
|
return;
|
|
663
701
|
}
|
|
@@ -714,9 +752,27 @@ function contextoDaSessao(r, worker) {
|
|
|
714
752
|
"(`tipo: \"politica\"` e `tipo: \"briefing\"`, uma chamada cada). Se essa " +
|
|
715
753
|
"ferramenta não estiver disponível nesta sessão, pare e diga ao usuário que " +
|
|
716
754
|
"o servidor MCP do dd-harness precisa estar conectado para isto.\n" +
|
|
755
|
+
// A politica e escrita do zero pelo agente, e este texto e o unico lugar que diz o
|
|
756
|
+
// que ela precisa conter. Sem esta linha, o roadmap chega ao contexto e nao obriga
|
|
757
|
+
// a nada: informacao passiva, que e como uma feature existe sem ser usada.
|
|
758
|
+
" Na política, inclua uma linha sobre o roadmap: a fase **Agora** é o foco da " +
|
|
759
|
+
"sessão, e ao terminá-la o agente **propõe** concluí-la (`editar_fase` com " +
|
|
760
|
+
"`status: \"concluida\"`) — nunca conclui por conta própria, porque quem decide " +
|
|
761
|
+
"que uma fase acabou é o usuário.\n" +
|
|
717
762
|
"5. Depois de escrever os dois, sobrescreva o `CLAUDE.md` da raiz com um " +
|
|
718
763
|
"ponteiro curto para a política deste serviço — a verdade passa a morar " +
|
|
719
|
-
"aqui, e o `CLAUDE.md` local nunca mais precisa ser editado à mão." +
|
|
764
|
+
"aqui, e o `CLAUDE.md` local nunca mais precisa ser editado à mão. O ponteiro " +
|
|
765
|
+
"precisa citar `ler_artefato` **e** `ler_roadmap`: quem chega por ele (Codex, " +
|
|
766
|
+
"Cursor, ou o Claude Code quando o hook não roda) não tem outro jeito de " +
|
|
767
|
+
"descobrir que há roadmap.\n" +
|
|
768
|
+
// O momento certo de perguntar: quem acabou de descrever o projeto tem o contexto
|
|
769
|
+
// fresco para dizer por onde ele vai. Depois disso, ninguem mais pergunta — e uma
|
|
770
|
+
// feature que so se descobre lendo a lista de ferramentas nao se descobre.
|
|
771
|
+
"6. Por fim, pergunte se o trabalho deste projeto tem **fases** — algo que " +
|
|
772
|
+
"atravessa sessões, com ordem entre as partes. Se tiver, proponha as primeiras e " +
|
|
773
|
+
"crie-as com `criar_fase`; a de menor ordem vira o foco de cada sessão, e " +
|
|
774
|
+
"concluí-la a move para o changelog sozinha. Se for um projeto pequeno, diga que " +
|
|
775
|
+
"roadmap é opcional e siga sem ele — não insista." +
|
|
720
776
|
fila,
|
|
721
777
|
};
|
|
722
778
|
}
|
|
@@ -908,6 +964,8 @@ async function principal() {
|
|
|
908
964
|
return comandoStatus();
|
|
909
965
|
case "politica":
|
|
910
966
|
return comandoPolitica(resto);
|
|
967
|
+
case "cinto":
|
|
968
|
+
return comandoCinto();
|
|
911
969
|
case "roadmap":
|
|
912
970
|
return comandoRoadmap();
|
|
913
971
|
case "changelog":
|
package/dist/cli/src/politica.js
CHANGED
|
@@ -30,9 +30,9 @@ export async function buscaPolitica(raiz) {
|
|
|
30
30
|
// existir sem briefing — a sessao seguia como "ok" com metade do briefing faltando,
|
|
31
31
|
// e nada acionava o fluxo que devia completa-lo.
|
|
32
32
|
if (!conteudo || payload.briefado === false) {
|
|
33
|
-
return { estado: "sem-politica", esperandoIndexacao };
|
|
33
|
+
return { estado: "sem-politica", esperandoIndexacao, memorias: payload.memorias };
|
|
34
34
|
}
|
|
35
|
-
return { estado: "ok", conteudo, esperandoIndexacao, roadmap };
|
|
35
|
+
return { estado: "ok", conteudo, esperandoIndexacao, roadmap, memorias: payload.memorias };
|
|
36
36
|
}
|
|
37
37
|
catch (erro) {
|
|
38
38
|
return { estado: "inalcancavel", motivo: mensagem(erro) };
|
package/dist/cli/src/roadmap.js
CHANGED
|
@@ -123,6 +123,27 @@ export function formataRoadmap(fases) {
|
|
|
123
123
|
: "Nenhuma fase concluída ainda.");
|
|
124
124
|
return linhas.join("\n");
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* A frase sobre POSICAO que fecha a resposta de `editar_fase` — vazia quando `ordem` nao
|
|
128
|
+
* foi pedida, porque quem edita texto nao perguntou nada sobre posicao.
|
|
129
|
+
*
|
|
130
|
+
* Existe por um achado medido (rodada 005): pedir `ordem: 1` para promover uma fase a
|
|
131
|
+
* "Agora" quando outra mais antiga ja tem `ordem: 1` grava o valor, responde sucesso e
|
|
132
|
+
* nao muda nada visivel — a leitura desempata por `created_at`. O comportamento esta
|
|
133
|
+
* certo; o silencio e que ensinava o agente a seguir achando que promoveu a fase.
|
|
134
|
+
*
|
|
135
|
+
* Confirma a ordem aplicada mesmo sem empate: e o unico jeito de quem pediu saber que o
|
|
136
|
+
* numero pegou, e custa uma linha.
|
|
137
|
+
*/
|
|
138
|
+
export function avisoDeOrdem(fase, atrasDe, pediuOrdem) {
|
|
139
|
+
if (!pediuOrdem)
|
|
140
|
+
return "";
|
|
141
|
+
if (!atrasDe)
|
|
142
|
+
return ` Ordem ${fase.ordem}${fase.status === "aberta" ? " — é o \"Agora\"" : ""}.`;
|
|
143
|
+
return (` Ordem ${fase.ordem}, mas ainda atrás de \`${atrasDe.slug}\` (${atrasDe.titulo}) —` +
|
|
144
|
+
" fases com a mesma ordem desempatam pela mais antiga. Para passar à frente, use uma" +
|
|
145
|
+
" ordem menor ou mude a ordem da outra.");
|
|
146
|
+
}
|
|
126
147
|
/**
|
|
127
148
|
* O changelog: fases concluidas agrupadas por versao, na ordem em que a API devolveu (mais
|
|
128
149
|
* recente primeiro). Grupo sem versao recebe `SEM_VERSAO`. A ordem dos grupos e a da
|
package/dist/mcp/src/index.js
CHANGED
|
@@ -12,7 +12,8 @@ import { criaPasta } from "../../cli/src/pasta.js";
|
|
|
12
12
|
import { criaProjeto } from "../../cli/src/projeto.js";
|
|
13
13
|
import { escreveArtefato, leArtefato } from "../../cli/src/artefato.js";
|
|
14
14
|
import { grava } from "../../cli/src/gravar.js";
|
|
15
|
-
import {
|
|
15
|
+
import { caminhosDaArvore } from "../../cli/src/diff.js";
|
|
16
|
+
import { avisoDeOrdem, criaFase, editaFase, formataChangelog, formataRoadmap, leFases, } from "../../cli/src/roadmap.js";
|
|
16
17
|
/**
|
|
17
18
|
* O mesmo servico, outra porta.
|
|
18
19
|
*
|
|
@@ -87,6 +88,31 @@ Os primeiros achados são os que valem: o piso barra tema alheio, mas num Brain
|
|
|
87
88
|
return falha(erro);
|
|
88
89
|
}
|
|
89
90
|
});
|
|
91
|
+
server.registerTool("sugerir_ancoras", {
|
|
92
|
+
description: `Os arquivos que esta sessão modificou, para escolher a âncora de uma memória que vai ser gravada.
|
|
93
|
+
|
|
94
|
+
Chame ANTES de \`gravar_memoria\` quando a memória fala de um ponto do código. A âncora é o que faz a memória ser entregue a quem for mexer naquele ponto — sem ela, a memória só aparece se alguém lembrar de buscar, e é justamente quando o agente acha que já sabe que ele não busca.
|
|
95
|
+
|
|
96
|
+
Isto NÃO escolhe por você: devolve o que mudou, e a âncora certa é a que aponta o trecho de que a memória fala. Prefira \`arquivo#trecho\` a \`arquivo\` inteiro — um texto estável e específico (nome de função, constante, chave de config), não uma linha que a próxima refatoração reescreve.
|
|
97
|
+
|
|
98
|
+
Lista vazia significa que não há git ou nada foi modificado; nesse caso pergunte ao usuário qual arquivo a memória guarda.`,
|
|
99
|
+
inputSchema: z.object({}),
|
|
100
|
+
}, async () => {
|
|
101
|
+
try {
|
|
102
|
+
const caminhos = await caminhosDaArvore(raiz);
|
|
103
|
+
if (caminhos.length === 0) {
|
|
104
|
+
return texto("Nada modificado nesta sessão (ou o projeto não tem git). Pergunte ao " +
|
|
105
|
+
"usuário qual arquivo a memória guarda — e grave com âncora, não sem.");
|
|
106
|
+
}
|
|
107
|
+
return texto(`${caminhos.length} arquivo(s) modificado(s) nesta sessão:\n` +
|
|
108
|
+
caminhos.map((c) => `- \`${c}\``).join("\n") +
|
|
109
|
+
"\n\nEscolha o que a memória descreve e ancore no TRECHO (`arquivo#alvo`), " +
|
|
110
|
+
"não no arquivo inteiro, quando a memória fala de um ponto específico.");
|
|
111
|
+
}
|
|
112
|
+
catch (erro) {
|
|
113
|
+
return falha(erro);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
90
116
|
server.registerTool("gravar_memoria", {
|
|
91
117
|
description: `Registra uma memória nova no Brain do projeto. O default é NÃO gravar: proponha ao humano antes, e grave o que ele aprovar.
|
|
92
118
|
|
|
@@ -110,6 +136,12 @@ A pasta precisa existir antes — use \`criar_pasta\`. Essa recusa é deliberada
|
|
|
110
136
|
try {
|
|
111
137
|
const r = await grava(raiz, arquivo);
|
|
112
138
|
return texto(`Gravado ${r.endereco} — ${r.ancoras} âncora(s), valendo para ${r.projetos} projeto(s).\n` +
|
|
139
|
+
(r.ancoras === 0
|
|
140
|
+
? "SEM ÂNCORA: esta memória só será encontrada por busca, e ninguém será " +
|
|
141
|
+
"avisado ao mexer no código que ela guarda — o alerta que chega antes da " +
|
|
142
|
+
"edição nasce da âncora. `sugerir_ancoras` mostra o que esta sessão tocou; " +
|
|
143
|
+
"proponha uma ao usuário.\n"
|
|
144
|
+
: "") +
|
|
113
145
|
"Rode `pnpm worker --uma-vez` para ela entrar na busca semântica.");
|
|
114
146
|
}
|
|
115
147
|
catch (erro) {
|
|
@@ -384,7 +416,7 @@ CONCLUIR uma fase é isto com \`status: "concluida"\`. Proponha ao humano antes
|
|
|
384
416
|
|
|
385
417
|
Fase concluída NÃO gera memória no Brain por padrão. O roadmap guarda o quê e quando; o porquê de uma decisão só vira memória se passar nos três filtros — e aí o caminho é \`gravar_memoria\`, não o conteúdo da fase.
|
|
386
418
|
|
|
387
|
-
\`versao: null\` tira a versão. \`ordem\` reposiciona: a aberta de menor ordem é o "Agora".`,
|
|
419
|
+
\`versao: null\` tira a versão. \`ordem\` reposiciona: a aberta de menor ordem é o "Agora". Duas fases podem ter a mesma ordem — aí a mais antiga vem primeiro, então repetir a ordem da fase atual NÃO promove a sua. A resposta diz em que posição a fase ficou e, se ela seguiu atrás de outra, qual.`,
|
|
388
420
|
inputSchema: z.object({
|
|
389
421
|
slug: z.string().min(1).describe("O endereço da fase, como aparece entre colchetes em `ler_roadmap` e `ler_changelog`."),
|
|
390
422
|
titulo: z.string().min(2).max(120).optional(),
|
|
@@ -395,12 +427,13 @@ Fase concluída NÃO gera memória no Brain por padrão. O roadmap guarda o quê
|
|
|
395
427
|
}),
|
|
396
428
|
}, async ({ slug, ...mudancas }) => {
|
|
397
429
|
try {
|
|
398
|
-
const { fase } = await editaFase(raiz, slug, mudancas);
|
|
430
|
+
const { fase, atras_de } = await editaFase(raiz, slug, mudancas);
|
|
399
431
|
// A frase de transicao so quando a conclusao foi o PEDIDO — editar o texto de uma
|
|
400
432
|
// fase ja concluida nao a faz "sair do roadmap" de novo.
|
|
401
433
|
return texto(`Fase \`${fase.slug}\` atualizada — ${fase.status}` +
|
|
402
434
|
(fase.versao ? `, versão ${fase.versao}` : "") +
|
|
403
|
-
(mudancas.status === "concluida" ? ". Saiu do roadmap e entrou no changelog." : ".")
|
|
435
|
+
(mudancas.status === "concluida" ? ". Saiu do roadmap e entrou no changelog." : ".") +
|
|
436
|
+
avisoDeOrdem(fase, atras_de, mudancas.ordem !== undefined));
|
|
404
437
|
}
|
|
405
438
|
catch (erro) {
|
|
406
439
|
return falha(erro);
|
package/package.json
CHANGED