dd-harness 0.18.0 → 0.20.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.
@@ -0,0 +1,75 @@
1
+ import type { Brain, Memoria } from "./brain.js";
2
+ /**
3
+ * O cinto de seguranca: a memoria chega ANTES da edicao, sem ninguem ter pedido.
4
+ *
5
+ * O Brain ate aqui e PULL — o agente precisa lembrar de chamar `buscar_memoria`. Isso
6
+ * falha exatamente onde mais custa: o agente nao busca quando *acha que sabe*. Ninguem
7
+ * consulta memoria antes de trocar um `SECURITY DEFINER` por `INVOKER` para silenciar um
8
+ * advisor; a tarefa parece trivial, e a melhor pratica da internet diz para fazer isso.
9
+ * A memoria existe justamente porque, neste projeto, isso quebrou a RLS inteira.
10
+ *
11
+ * O PUSH inverte: o hook `PreToolUse` cruza o arquivo que o agente esta editando contra
12
+ * as ancoras e injeta a memoria como `additionalContext`. AVISA, NUNCA BLOQUEIA — mesma
13
+ * regra da deriva e dos ganchos. Bloquear antes de o mecanismo ter reputacao ensina o
14
+ * reflexo do `--no-verify`, e ai nao ha cinto nenhum.
15
+ *
16
+ * Tudo local, de proposito. O hook e SINCRONO: cada `Edit` espera ele terminar. Medido
17
+ * contra producao, a rede custa ~250ms quente e ate 1,9s no cold start — por edicao. O
18
+ * cruzamento local custa ~100ms, quase tudo boot do Node. Por isso o cache: o payload do
19
+ * `SessionStart` ja traz ancoras, resumo e corpo, entao ele sai de graca, sem endpoint
20
+ * novo e sem uma segunda chamada.
21
+ */
22
+ /** Onde o cache vive. Fora do repositorio: nada do dd-harness volta a morar em disco versionado. */
23
+ export declare function caminhoDoCache(raiz: string): string;
24
+ /**
25
+ * So o que o cruzamento precisa. O payload inteiro tem briefing, politica e o corpo de
26
+ * toda memoria — carregar isso a cada `Edit` seria pagar leitura de disco grande para,
27
+ * quase sempre, nao casar com nada.
28
+ */
29
+ export type CacheDeAncoras = {
30
+ gravado_em: string;
31
+ memorias: Pick<Memoria, "pasta" | "slug" | "titulo" | "resumo" | "status" | "ancoras">[];
32
+ };
33
+ /** Guarda as ancoras do Brain para o hook consultar sem rede. Falha em silencio: cache e otimizacao, nao contrato. */
34
+ export declare function guardaCache(raiz: string, brain: Brain): Promise<void>;
35
+ export declare function leCache(raiz: string): Promise<CacheDeAncoras | null>;
36
+ /** O que o hook recebe do Claude Code no stdin. So os campos que usamos. */
37
+ export type EntradaDoHook = {
38
+ cwd?: string;
39
+ session_id?: string;
40
+ tool_name?: string;
41
+ tool_input?: {
42
+ file_path?: string;
43
+ old_string?: string;
44
+ new_string?: string;
45
+ content?: string;
46
+ };
47
+ };
48
+ /**
49
+ * O caminho do arquivo relativo a raiz, no formato das ancoras (barras normais).
50
+ *
51
+ * `null` quando a edicao e fora do repositorio: `..` no relativo. Ancora nunca aponta
52
+ * para fora — o CHECK do banco recusa — entao nao ha o que cruzar, e avisar seria ruido.
53
+ */
54
+ export declare function caminhoRelativo(raiz: string, arquivo: string): string | null;
55
+ /**
56
+ * A memoria que o agente precisa ver ANTES de escrever — ou `""` quando nao ha nenhuma.
57
+ *
58
+ * Vazio e o caso comum e tem que sair barato: a maioria das edicoes nao toca ancora
59
+ * alguma, e o silencio ai nao e falta de aviso, e a ausencia de motivo para avisar.
60
+ */
61
+ export declare function alerta(tocadas: {
62
+ titulo: string;
63
+ pasta: string;
64
+ memoria: string;
65
+ ancora: string;
66
+ }[], resumos: Map<string, string>): string;
67
+ /**
68
+ * O hook inteiro: le a entrada, cruza, devolve o JSON que o Claude Code espera.
69
+ *
70
+ * SEMPRE `permissionDecision: "allow"` — provado no runtime que `additionalContext`
71
+ * chega ao agente junto com `allow`, sem bloquear a edicao. Esse par e o que torna o
72
+ * "avisa, nao impede" possivel aqui; com `deny` o aviso existiria so as custas de
73
+ * recusar a edicao.
74
+ */
75
+ export declare function decideDoHook(entrada: EntradaDoHook): Promise<string>;
package/dist/cinto.js ADDED
@@ -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/diff.d.ts CHANGED
@@ -95,3 +95,39 @@ export declare function sugereReancoragem(ausentes: {
95
95
  * e o que a mensagem mostra: quem le precisa saber qual trecho a memoria guarda.
96
96
  */
97
97
  export declare function memoriasTocadas(brain: Brain, caminhos: string[]): Tocada[];
98
+ /**
99
+ * O que a arvore de trabalho tem de modificado AGORA — sem commit nenhum.
100
+ *
101
+ * `caminhosDoCommit` responde "o que aquele commit mudou"; esta responde "o que esta
102
+ * mudado neste instante". E a diferenca entre o code review (depois) e a sessao em
103
+ * andamento (durante), e e o que permite propor ancora no momento em que a memoria nasce:
104
+ * quem grava uma memoria acabou de mexer no que ela descreve.
105
+ *
106
+ * Inclui nao rastreados (`--others`): arquivo novo e exatamente onde a decisao recem
107
+ * tomada costuma morar, e exclui-lo deixaria de fora o caso mais comum.
108
+ *
109
+ * Lista vazia quando nao ha git — sem lancar. Propor ancora e um extra; um projeto sem
110
+ * repositorio continua gravando memoria normalmente, so sem a sugestao.
111
+ */
112
+ export declare function caminhosDaArvore(raiz: string): Promise<string[]>;
113
+ /**
114
+ * Cruza ancoras com o CONTEUDO de uma edicao, nao so com o nome do arquivo.
115
+ *
116
+ * `memoriasTocadas` casa por arquivo porque e tudo que o `git diff-tree` permite — ele
117
+ * devolve caminho puro, nunca o trecho. Aqui e diferente: quem chama tem o texto que vai
118
+ * ser escrito, entao a ancora de trecho pode ser verificada de verdade.
119
+ *
120
+ * A diferenca importa porque e o que separa aviso de ruido. Medido neste projeto: duas
121
+ * memorias ancoradas no mesmo `config/limites.json` eram as duas sinalizadas quando so
122
+ * uma das chaves mudava. Numa interrupcao nao solicitada — que e o que o interceptador
123
+ * faz — errar assim ensina a ignorar o aviso, e um aviso ignorado e pior que nenhum.
124
+ *
125
+ * A regra, por tipo de ancora:
126
+ * - `arquivo` -> casa sempre que o arquivo casa. A memoria fala do todo.
127
+ * - `arquivo#trecho` -> casa so se o trecho aparecer no texto ANTES ou DEPOIS da edicao.
128
+ * Antes: a edicao esta mexendo onde a memoria guarda. Depois: esta mexendo para la.
129
+ *
130
+ * `antes` ausente (arquivo novo, ou `Write` que nao le o original) cai so no `depois` —
131
+ * sem inventar casamento por falta de dado.
132
+ */
133
+ export declare function memoriasNoConteudo(brain: Brain, caminho: string, antes: string | null, depois: string | null): Tocada[];
package/dist/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 jaTemONosso = JSON.stringify(sessionStart).includes(COMANDO_DO_HOOK);
27
- if (jaTemONosso)
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
- sessionStart.push({
31
- hooks: [
32
- {
33
- type: "command",
34
- command: COMANDO_DO_HOOK,
35
- statusMessage: "Carregando a política do dd-harness...",
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" };
package/dist/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
  }
@@ -926,6 +964,8 @@ async function principal() {
926
964
  return comandoStatus();
927
965
  case "politica":
928
966
  return comandoPolitica(resto);
967
+ case "cinto":
968
+ return comandoCinto();
929
969
  case "roadmap":
930
970
  return comandoRoadmap();
931
971
  case "changelog":
@@ -1,3 +1,4 @@
1
+ import type { Memoria } from "./brain.js";
1
2
  import type { RoadmapDaSessao } from "./roadmap.js";
2
3
  /**
3
4
  * A politica do projeto, buscada no servico na hora.
@@ -37,5 +38,11 @@ export type ResultadoDaPolitica = ({
37
38
  * nao imprime nada.
38
39
  */
39
40
  roadmap?: RoadmapDaSessao;
41
+ /**
42
+ * As memorias, de carona no mesmo payload. Servem para o cache de ancoras que o
43
+ * interceptador pre-voo consulta a cada edicao: o hook ja paga esta requisicao, e o
44
+ * cruzamento local nao pode pagar outra — ele e sincrono e roda a cada `Edit`.
45
+ */
46
+ memorias?: Memoria[];
40
47
  };
41
48
  export declare function buscaPolitica(raiz: string): Promise<ResultadoDaPolitica>;
package/dist/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/roadmap.d.ts CHANGED
@@ -62,8 +62,17 @@ export declare function criaFase(raiz: string, dados: NovaFase): Promise<{
62
62
  fase: Fase;
63
63
  criou: boolean;
64
64
  }>;
65
+ /**
66
+ * A fase aberta imediatamente a frente, quando `ordem` foi pedida — `null` se a editada
67
+ * ficou na frente de todas, e ausente quando `ordem` nem foi mencionada.
68
+ */
69
+ export type AtrasDe = {
70
+ slug: string;
71
+ titulo: string;
72
+ } | null;
65
73
  export declare function editaFase(raiz: string, slug: string, dados: EdicaoDeFase): Promise<{
66
74
  fase: Fase;
75
+ atras_de: AtrasDe;
67
76
  }>;
68
77
  /** Rotulo do grupo de fases concluidas sem versao: o "Unreleased" do Keep a Changelog. */
69
78
  export declare const SEM_VERSAO = "Sem vers\u00E3o (ainda n\u00E3o lan\u00E7ado)";
@@ -82,6 +91,19 @@ export declare function blocoDeSessao(r: RoadmapDaSessao | null | undefined): st
82
91
  * separa por status. Concluidas nao aparecem aqui — sao changelog.
83
92
  */
84
93
  export declare function formataRoadmap(fases: Fase[]): string;
94
+ /**
95
+ * A frase sobre POSICAO que fecha a resposta de `editar_fase` — vazia quando `ordem` nao
96
+ * foi pedida, porque quem edita texto nao perguntou nada sobre posicao.
97
+ *
98
+ * Existe por um achado medido (rodada 005): pedir `ordem: 1` para promover uma fase a
99
+ * "Agora" quando outra mais antiga ja tem `ordem: 1` grava o valor, responde sucesso e
100
+ * nao muda nada visivel — a leitura desempata por `created_at`. O comportamento esta
101
+ * certo; o silencio e que ensinava o agente a seguir achando que promoveu a fase.
102
+ *
103
+ * Confirma a ordem aplicada mesmo sem empate: e o unico jeito de quem pediu saber que o
104
+ * numero pegou, e custa uma linha.
105
+ */
106
+ export declare function avisoDeOrdem(fase: Pick<Fase, "ordem" | "status">, atrasDe: AtrasDe, pediuOrdem: boolean): string;
85
107
  /**
86
108
  * O changelog: fases concluidas agrupadas por versao, na ordem em que a API devolveu (mais
87
109
  * recente primeiro). Grupo sem versao recebe `SEM_VERSAO`. A ordem dos grupos e a da
package/dist/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dd-harness",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "Cliente do dd-harness: politica no inicio da sessao, e memoria por busca — nada em disco. Sem dependencia: fetch, crypto e fs sao do Node.",
6
6
  "license": "UNLICENSED",