dd-harness-mcp 0.9.0 → 0.11.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.
@@ -99,6 +99,7 @@ export async function check(raiz, commit) {
99
99
  fechadas: 0,
100
100
  tocadas,
101
101
  reancoragens,
102
+ semGit: mudancas.semGit,
102
103
  };
103
104
  }
104
105
  const envio = await pede(`${config.api}/api/v1/deriva`, {
@@ -127,5 +128,6 @@ export async function check(raiz, commit) {
127
128
  fechadas: julgamento.fechadas ?? 0,
128
129
  tocadas,
129
130
  reancoragens,
131
+ semGit: mudancas.semGit,
130
132
  };
131
133
  }
@@ -17,10 +17,34 @@ export async function caminhosDoCommit(raiz, commit) {
17
17
  const { stdout } = await roda("git", ["diff-tree", "--no-commit-id", "-r", "-M", "--name-status", commit], { cwd: raiz });
18
18
  return interpretaNameStatus(stdout);
19
19
  }
20
- catch {
21
- return { caminhos: [], renames: [] };
20
+ catch (erro) {
21
+ // Continua sem lancar o gancho `post-commit` nao pode falhar por causa de um aviso.
22
+ // O que muda e que a razao volta junto, para quem chamou poder dize-la em vez de
23
+ // mostrar "nenhuma mudanca" quando na verdade nada foi consultado.
24
+ return { caminhos: [], renames: [], semGit: classificaFalhaDoGit(erro) };
22
25
  }
23
26
  }
27
+ /**
28
+ * Traduz a falha do `git diff-tree` nos tres casos que pedem respostas diferentes de quem
29
+ * chamou: consertar o repositorio, conferir o SHA, ou instalar o git.
30
+ *
31
+ * A leitura e do stderr porque o `git` usa o mesmo codigo de saida (128) para "not a git
32
+ * repository" e para "bad object" — o texto e o unico jeito de separar os dois.
33
+ */
34
+ function classificaFalhaDoGit(erro) {
35
+ const e = erro;
36
+ if (e.code === "ENOENT")
37
+ return "git-indisponivel";
38
+ const stderr = e.stderr ?? "";
39
+ if (/not a git repository/i.test(stderr))
40
+ return "sem-repositorio";
41
+ if (/bad object|unknown revision|ambiguous argument/i.test(stderr)) {
42
+ return "commit-desconhecido";
43
+ }
44
+ // Falha que nao sabemos nomear: ainda e melhor avisar que o cruzamento nao aconteceu do
45
+ // que deixar passar como silencio.
46
+ return "git-indisponivel";
47
+ }
24
48
  /**
25
49
  * Le a saida de `--name-status`: uma letra de status, TAB, e um ou dois caminhos.
26
50
  *
@@ -63,13 +63,20 @@ export async function escreveMcp(raiz) {
63
63
  return { ok: true, estado: eraVazio ? "criado" : "acrescentado" };
64
64
  }
65
65
  /**
66
- * `AGENTS.md` e markdown livre, nao JSONnao ha "mesclar" de verdade. Se o arquivo ja
67
- * existe (com qualquer conteudo), so acrescenta a instrucao no fim; nunca sobrescreve
68
- * o que a pessoa escreveu, porque markdown de outra ferramenta e mais dificil de separar
69
- * do nosso do que uma chave de objeto.
66
+ * Os dois pontos de entrada do repositorio `CLAUDE.md` (Claude Code) e `AGENTS.md`
67
+ * (Codex, Cursor, Gemini CLI, Windsurf) sao ESPELHOS: o mesmo apontamento para o
68
+ * dd-harness, sem regra propria. Nenhum dos dois e opcional, e nao ha pergunta sobre
69
+ * "qual ferramenta voce usa": quem abre o repositorio encontra o protocolo pela porta
70
+ * que a SUA ferramenta le, e as duas dizem a mesma coisa. Regra fica no servico; aqui
71
+ * fica so o ponteiro — por isso espelhar nao duplica nada que possa divergir.
72
+ *
73
+ * Markdown e livre, nao JSON: nao ha "mesclar" de verdade. Se o arquivo ja existe (com
74
+ * qualquer conteudo), so acrescenta a instrucao no fim; nunca sobrescreve o que a pessoa
75
+ * escreveu, porque markdown alheio e mais dificil de separar do nosso do que uma chave
76
+ * de objeto.
70
77
  */
71
- export async function escreveAgents(raiz) {
72
- const caminho = join(raiz, "AGENTS.md");
78
+ export async function escrevePonteiro(raiz, arquivo) {
79
+ const caminho = join(raiz, arquivo);
73
80
  const instrucao = SUGESTAO_AGENTS;
74
81
  let atual = null;
75
82
  try {
@@ -88,14 +95,16 @@ export async function escreveAgents(raiz) {
88
95
  await writeFile(caminho, `${atual}${separador}${instrucao}\n`, "utf8");
89
96
  return { estado: "acrescentado" };
90
97
  }
91
- const SUGESTAO_AGENTS = `## Protocolo do dd-harness
92
-
93
- Este projeto tem um protocolo de trabalho que **não está neste arquivo**.
94
-
95
- **ANTES de qualquer outra coisa**, chame a ferramenta MCP \`ler_artefato\` com
96
- \`tipo: "politica"\` e siga o que ela devolver. É a primeira ação da sessão, antes
97
- de ler código, responder ou planejar.
98
-
99
- - Se a ferramenta não existir, o servidor MCP do dd-harness não está declarado:
100
- avise o usuário e **não modifique nada** até ele resolver.
98
+ /** Compatibilidade: `init` ainda chama pelo nome antigo. */
99
+ export const escreveAgents = (raiz) => escrevePonteiro(raiz, "AGENTS.md");
100
+ const SUGESTAO_AGENTS = `## Protocolo do dd-harness
101
+
102
+ Este projeto tem um protocolo de trabalho que **não está neste arquivo**.
103
+
104
+ **ANTES de qualquer outra coisa**, chame a ferramenta MCP \`ler_artefato\` com
105
+ \`tipo: "politica"\` e siga o que ela devolver. É a primeira ação da sessão, antes
106
+ de ler código, responder ou planejar.
107
+
108
+ - Se a ferramenta não existir, o servidor MCP do dd-harness não está declarado:
109
+ avise o usuário e **não modifique nada** até ele resolver.
101
110
  - Se ela devolver vazio, este projeto nunca foi briefado — diga isso ao usuário.`;
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { access } from "node:fs/promises";
3
+ import { access, readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  // DEP0190: Node imprime esse warning direto no stderr no instante em que
6
6
  // `spawn(..., { shell: true })` recebe um array de args — de forma sincrona, ANTES de
@@ -14,11 +14,12 @@ process.removeAllListeners("warning");
14
14
  import { termosDaConsulta } from "./argv.js";
15
15
  import { check, status } from "./check.js";
16
16
  import { CAMINHO_CONFIG, guardaConfigDaMaquina, guardaToken, leConfigDaMaquina, leConfigDoRepo, leToken, } from "./config.js";
17
- import { escreveAgents, escreveHook, escreveMcp } from "./escreve-config.js";
17
+ import { escreveHook, escreveMcp, escrevePonteiro } from "./escreve-config.js";
18
18
  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
23
  import { busca } from "./buscar.js";
23
24
  import { arquiva, edita, le } from "./curar.js";
24
25
  import { criaPasta } from "./pasta.js";
@@ -61,6 +62,16 @@ const AJUDA = `dd-harness — a política e o Brain do projeto, no serviço
61
62
  1 = não consegui buscar
62
63
  --hook: fala o protocolo do SessionStart do
63
64
  Claude Code, para pôr a política no contexto
65
+ dd-harness roadmap as fases abertas: a atual inteira, as próximas
66
+ por título (opcional — projeto sem fase não tem)
67
+ dd-harness changelog [--versao <v>]
68
+ o que já foi concluído, agrupado por versão
69
+ dd-harness fase criar --titulo "<t>" [--conteudo <arquivo.md>] [--versao <v>]
70
+ [--status ideia|aberta|concluida] [--slug <s>]
71
+ dd-harness fase editar <slug> [--titulo "<t>"] [--conteudo <arquivo.md>]
72
+ [--versao <v> | --sem-versao] [--status <s>] [--ordem <n>]
73
+ concluir = --status concluida: a fase sai do
74
+ roadmap e entra no changelog, nada migra
64
75
  dd-harness --help
65
76
  dd-harness --version qual binário está instalado nesta máquina
66
77
 
@@ -88,6 +99,77 @@ function argumento(argv, nome) {
88
99
  return i >= 0 ? argv[i + 1] : undefined;
89
100
  }
90
101
  const API_PADRAO = "https://dd-harness.vercel.app";
102
+ // --- roadmap e changelog ---
103
+ async function comandoRoadmap() {
104
+ console.log(formataRoadmap(await leFases(process.cwd())));
105
+ }
106
+ async function comandoChangelog(argv) {
107
+ const versao = argumento(argv, "versao");
108
+ const fases = await leFases(process.cwd(), { status: "concluida", ...(versao ? { versao } : {}) });
109
+ console.log(formataChangelog(fases));
110
+ }
111
+ const STATUS_DE_FASE = ["ideia", "aberta", "concluida", "descartada"];
112
+ function statusDoArgv(argv) {
113
+ const s = argumento(argv, "status");
114
+ if (s === undefined)
115
+ return undefined;
116
+ if (!STATUS_DE_FASE.includes(s)) {
117
+ throw new Error(`--status: use ${STATUS_DE_FASE.join(", ")}.`);
118
+ }
119
+ return s;
120
+ }
121
+ /** `--conteudo` aponta para um .md, como `gravar` e `editar` — markdown de verdade nao cabe em flag. */
122
+ async function conteudoDoArgv(argv) {
123
+ const caminho = argumento(argv, "conteudo");
124
+ return caminho === undefined ? undefined : readFile(caminho, "utf8");
125
+ }
126
+ /**
127
+ * `fase criar` e `fase editar`. Concluir uma fase e `fase editar <slug> --status concluida`
128
+ * — nao ha verbo proprio de proposito: e uma edicao de status como qualquer outra, e e o
129
+ * banco que a transforma em changelog.
130
+ */
131
+ async function comandoFase(argv) {
132
+ const [acao, ...resto] = argv;
133
+ if (acao === "criar") {
134
+ const titulo = argumento(resto, "titulo");
135
+ if (!titulo)
136
+ throw new Error('fase criar exige --titulo "<título>".');
137
+ const status = statusDoArgv(resto);
138
+ if (status === "descartada")
139
+ throw new Error("não se cria uma fase já descartada.");
140
+ const { fase } = await criaFase(process.cwd(), {
141
+ titulo,
142
+ conteudo: await conteudoDoArgv(resto),
143
+ versao: argumento(resto, "versao"),
144
+ status,
145
+ slug: argumento(resto, "slug"),
146
+ });
147
+ console.log(`Fase ${fase.slug} criada — ordem ${fase.ordem}, ${fase.status}` +
148
+ (fase.versao ? `, versão ${fase.versao}` : "") +
149
+ ".");
150
+ return;
151
+ }
152
+ if (acao === "editar") {
153
+ const [slug, ...opcoes] = resto;
154
+ if (!slug || slug.startsWith("--"))
155
+ throw new Error("fase editar exige o slug da fase.");
156
+ const ordemBruta = argumento(opcoes, "ordem");
157
+ const status = statusDoArgv(opcoes);
158
+ const { fase } = await editaFase(process.cwd(), slug, {
159
+ titulo: argumento(opcoes, "titulo"),
160
+ conteudo: await conteudoDoArgv(opcoes),
161
+ versao: opcoes.includes("--sem-versao") ? null : argumento(opcoes, "versao"),
162
+ status,
163
+ ordem: ordemBruta === undefined ? undefined : Number(ordemBruta),
164
+ });
165
+ console.log(`Fase ${fase.slug} atualizada — ${fase.status}` +
166
+ (fase.versao ? `, versão ${fase.versao}` : "") +
167
+ (status === "concluida" ? " (saiu do roadmap, entrou no changelog)" : "") +
168
+ ".");
169
+ return;
170
+ }
171
+ throw new Error('uso: dd-harness fase criar --titulo "<t>" [...] | dd-harness fase editar <slug> [...]');
172
+ }
91
173
  /**
92
174
  * Forca o `npx` a baixar e cachear `dd-harness-mcp` AGORA, dentro do wizard — nunca na
93
175
  * primeira conexao do Claude Code. Sem isto, a primeira instalacao acontecia so quando
@@ -287,15 +369,18 @@ async function comandoStart() {
287
369
  "conexão do Claude Code pode demorar ou falhar; se falhar, abra uma nova sessão " +
288
370
  "e tente de novo.");
289
371
  }
290
- // 6. AGENTS.md — so quem usa outra ferramenta alem do Claude Code precisa.
291
- const outraFerramenta = await pergunta("\nVai abrir este projeto em Codex, Cursor ou outra ferramenta além do Claude " +
292
- "Code? [s/N]: ");
293
- if (/^s/i.test(outraFerramenta)) {
294
- const agents = await escreveAgents(process.cwd());
295
- console.log({ criado: "criado AGENTS.md",
296
- "ja-tinha": "mantido AGENTS.md — já apontava para a política",
297
- acrescentado: "atualizado AGENTS.md (instrução acrescentada ao que já existia)",
298
- }[agents.estado]);
372
+ // 6. CLAUDE.md e AGENTS.md — os dois SEMPRE, sem perguntar. Sao espelhos: o mesmo
373
+ // apontamento para o servico, cada um na porta que uma familia de ferramenta le. A
374
+ // pergunta "usa outra ferramenta?" saiu porque a resposta nao muda nada que valha a
375
+ // pena — quem responde "nao" hoje abre o repositorio no Cursor semana que vem e nao
376
+ // encontra protocolo nenhum, e o custo de ja ter o arquivo e um ponteiro de 8 linhas.
377
+ console.log("");
378
+ for (const arquivo of ["CLAUDE.md", "AGENTS.md"]) {
379
+ const r = await escrevePonteiro(process.cwd(), arquivo);
380
+ console.log({ criado: `criado ${arquivo}`,
381
+ "ja-tinha": `mantido ${arquivo} — já apontava para a política`,
382
+ acrescentado: `atualizado ${arquivo} (apontamento acrescentado ao que já existia)`,
383
+ }[r.estado]);
299
384
  }
300
385
  // 7. Onde esta o monorepo do worker nesta maquina? So pergunta uma vez, e so importa
301
386
  // se houver memoria na fila agora — pular aqui nao trava nada, so avisa mais vezes.
@@ -468,8 +553,29 @@ async function comandoReancorar(argv) {
468
553
  console.log(` para ${r.para}`);
469
554
  console.log(" Rode `dd-harness check --commit <sha>` para medir a base nova.");
470
555
  }
556
+ /**
557
+ * Pediram `--commit` e o cruzamento com o diff nao aconteceu. Avisa ANTES do resto: sem
558
+ * isto a saida e identica a de um commit que nao tocou em nada indexado, e quem pediu o
559
+ * cruzamento nao tem como saber que ele nao existiu.
560
+ *
561
+ * Aviso, nao erro: o gancho `post-commit` termina em sucesso mesmo assim, e a medicao das
562
+ * ancoras (que nao depende de git) continua valendo.
563
+ */
564
+ function avisaSemGit(motivo) {
565
+ const texto = {
566
+ "sem-repositorio": "esta pasta não é um repositório git — não há diff para cruzar com as âncoras.",
567
+ "commit-desconhecido": "o git não reconhece este commit — confira o SHA.",
568
+ "git-indisponivel": "não consegui rodar o git aqui.",
569
+ }[motivo];
570
+ console.log(`AVISO: ${texto}`);
571
+ console.log(" As âncoras foram medidas assim mesmo; só o cruzamento com o");
572
+ console.log(" commit ficou de fora.");
573
+ console.log("");
574
+ }
471
575
  async function comandoCheck(argv) {
472
576
  const r = await check(process.cwd(), argumento(argv, "commit"));
577
+ if (r.semGit)
578
+ avisaSemGit(r.semGit);
473
579
  if (r.medidas === 0) {
474
580
  console.log("nenhuma âncora para medir.");
475
581
  return;
@@ -585,6 +691,9 @@ function contextoDaSessao(r, worker) {
585
691
  additionalContext: "# Política deste projeto (carregada do dd-harness)\n\n" +
586
692
  "As regras abaixo valem para esta sessão inteira.\n\n" +
587
693
  r.conteudo +
694
+ // Depois da politica, antes dos avisos operacionais: o roadmap e contexto de
695
+ // trabalho, a fila e ruido de infraestrutura. Vazio quando nao ha fase aberta.
696
+ blocoDeSessao(r.roadmap) +
588
697
  fila,
589
698
  };
590
699
  }
@@ -799,6 +908,12 @@ async function principal() {
799
908
  return comandoStatus();
800
909
  case "politica":
801
910
  return comandoPolitica(resto);
911
+ case "roadmap":
912
+ return comandoRoadmap();
913
+ case "changelog":
914
+ return comandoChangelog(resto);
915
+ case "fase":
916
+ return comandoFase(resto);
802
917
  case "--version":
803
918
  case "-V":
804
919
  case "version":
@@ -24,6 +24,7 @@ export async function buscaPolitica(raiz) {
24
24
  const payload = (await resposta.json());
25
25
  const conteudo = payload.politica?.trim();
26
26
  const esperandoIndexacao = payload.esperando_indexacao ?? 0;
27
+ const roadmap = payload.roadmap;
27
28
  // `briefado` e do SERVICO, nao inferido aqui: exige politica E briefing, os dois
28
29
  // obrigatorios. So checar `politica` vazia deixava passar o caso de politica
29
30
  // existir sem briefing — a sessao seguia como "ok" com metade do briefing faltando,
@@ -31,7 +32,7 @@ export async function buscaPolitica(raiz) {
31
32
  if (!conteudo || payload.briefado === false) {
32
33
  return { estado: "sem-politica", esperandoIndexacao };
33
34
  }
34
- return { estado: "ok", conteudo, esperandoIndexacao };
35
+ return { estado: "ok", conteudo, esperandoIndexacao, roadmap };
35
36
  }
36
37
  catch (erro) {
37
38
  return { estado: "inalcancavel", motivo: mensagem(erro) };
@@ -0,0 +1,153 @@
1
+ import { cabecalhos, credencial, pede, recusa } from "./api.js";
2
+ // --- rede ---
3
+ export async function leFases(raiz, filtro = {}) {
4
+ const { config, token } = await credencial(raiz);
5
+ const url = new URL(`${config.api}/api/v1/roadmap`);
6
+ url.searchParams.set("tenant", config.tenant);
7
+ url.searchParams.set("projeto", config.projeto);
8
+ if (filtro.status)
9
+ url.searchParams.set("status", filtro.status);
10
+ if (filtro.versao)
11
+ url.searchParams.set("versao", filtro.versao);
12
+ const resposta = await pede(url, { headers: cabecalhos(token) });
13
+ if (!resposta.ok)
14
+ await recusa(resposta);
15
+ return (await resposta.json()).fases;
16
+ }
17
+ export async function leFase(raiz, slug) {
18
+ const { config, token } = await credencial(raiz);
19
+ const url = new URL(`${config.api}/api/v1/roadmap/${encodeURIComponent(slug)}`);
20
+ url.searchParams.set("tenant", config.tenant);
21
+ url.searchParams.set("projeto", config.projeto);
22
+ const resposta = await pede(url, { headers: cabecalhos(token) });
23
+ if (!resposta.ok)
24
+ await recusa(resposta);
25
+ return (await resposta.json()).fase;
26
+ }
27
+ export async function criaFase(raiz, dados) {
28
+ const { config, token } = await credencial(raiz);
29
+ const resposta = await pede(`${config.api}/api/v1/roadmap`, {
30
+ method: "POST",
31
+ headers: cabecalhos(token, true),
32
+ body: JSON.stringify({ tenant: config.tenant, projeto: config.projeto, ...dados }),
33
+ });
34
+ if (!resposta.ok)
35
+ await recusa(resposta);
36
+ return (await resposta.json());
37
+ }
38
+ export async function editaFase(raiz, slug, dados) {
39
+ const { config, token } = await credencial(raiz);
40
+ const resposta = await pede(`${config.api}/api/v1/roadmap/${encodeURIComponent(slug)}`, {
41
+ method: "PATCH",
42
+ headers: cabecalhos(token, true),
43
+ body: JSON.stringify({ tenant: config.tenant, projeto: config.projeto, ...dados }),
44
+ });
45
+ if (!resposta.ok)
46
+ await recusa(resposta);
47
+ return (await resposta.json());
48
+ }
49
+ // --- formatacao (pura) ---
50
+ /** Rotulo do grupo de fases concluidas sem versao: o "Unreleased" do Keep a Changelog. */
51
+ export const SEM_VERSAO = "Sem versão (ainda não lançado)";
52
+ const rotuloDeVersao = (versao) => (versao ? ` (${versao})` : "");
53
+ /**
54
+ * O bloco que entra no contexto da sessao, colado depois da politica. Vazio quando o
55
+ * projeto nao tem fase aberta: aviso sem motivo em toda sessao e o que ensina a ignorar
56
+ * aviso — e roadmap e opcional.
57
+ *
58
+ * So a fase atual vai em texto integral. As proximas vao por titulo, e as concluidas nem
59
+ * isso — so a contagem. E a reducao de tokens que o modelo file-based nao tinha como
60
+ * fazer: la o arquivo entrava inteiro, feito e por fazer.
61
+ */
62
+ export function blocoDeSessao(r) {
63
+ if (!r?.agora)
64
+ return "";
65
+ const linhas = [
66
+ "",
67
+ "",
68
+ "---",
69
+ "",
70
+ "# Roadmap deste projeto (dd-harness)",
71
+ "",
72
+ `## Agora — ${r.agora.titulo}${rotuloDeVersao(r.agora.versao)}`,
73
+ "",
74
+ r.agora.conteudo.trim() || "_(fase sem descrição — pergunte ao usuário o que ela cobre.)_",
75
+ ];
76
+ if (r.a_seguir.length > 0) {
77
+ linhas.push("", "## A seguir");
78
+ for (const f of r.a_seguir)
79
+ linhas.push(`- ${f.titulo}${rotuloDeVersao(f.versao)}`);
80
+ }
81
+ linhas.push("", r.concluidas > 0
82
+ ? `${r.concluidas} fase(s) já concluída(s) — \`ler_changelog\` mostra o que foi entregue.`
83
+ : "Nenhuma fase concluída ainda.", "", "É da fase **Agora** que saem os passos desta sessão. Quando ela terminar, proponha ao " +
84
+ "usuário marcá-la concluída (`editar_fase` com `status: \"concluida\"`) — ela sai do " +
85
+ "roadmap e entra no changelog sozinha. O roadmap guarda o quê e quando; o porquê das " +
86
+ "decisões vai ao Brain, pelos três filtros, e fase concluída não gera memória por padrão.");
87
+ return linhas.join("\n");
88
+ }
89
+ /**
90
+ * O roadmap para o terminal e para `ler_roadmap`: recebe TODAS as fases do projeto e
91
+ * separa por status. Concluidas nao aparecem aqui — sao changelog.
92
+ */
93
+ export function formataRoadmap(fases) {
94
+ const porOrdem = (a, b) => a.ordem - b.ordem;
95
+ const abertas = fases.filter((f) => f.status === "aberta").sort(porOrdem);
96
+ const ideias = fases.filter((f) => f.status === "ideia").sort(porOrdem);
97
+ const concluidas = fases.filter((f) => f.status === "concluida").length;
98
+ if (abertas.length === 0 && ideias.length === 0) {
99
+ return ("Este projeto não tem roadmap." +
100
+ (concluidas > 0 ? ` (${concluidas} fase(s) concluída(s) no changelog.)` : "") +
101
+ "\nCrie a primeira fase: `dd-harness fase criar --titulo \"...\"`.");
102
+ }
103
+ const linhas = [];
104
+ const [agora, ...aSeguir] = abertas;
105
+ if (agora) {
106
+ linhas.push(`## Agora — ${agora.titulo}${rotuloDeVersao(agora.versao)} [${agora.slug}]`, "");
107
+ linhas.push(agora.conteudo.trim() || "_(sem descrição)_");
108
+ }
109
+ if (aSeguir.length > 0) {
110
+ linhas.push("", "## A seguir");
111
+ for (const f of aSeguir)
112
+ linhas.push(`- ${f.titulo}${rotuloDeVersao(f.versao)} [${f.slug}]`);
113
+ }
114
+ if (ideias.length > 0) {
115
+ linhas.push("", "## Ideias (não comprometidas)");
116
+ for (const f of ideias)
117
+ linhas.push(`- ${f.titulo}${rotuloDeVersao(f.versao)} [${f.slug}]`);
118
+ }
119
+ // Neutro de proposito: este texto sai igual no terminal e na ferramenta MCP, e cada
120
+ // porta tem o seu jeito de abrir o changelog.
121
+ linhas.push("", concluidas > 0
122
+ ? `${concluidas} fase(s) concluída(s) — estão no changelog.`
123
+ : "Nenhuma fase concluída ainda.");
124
+ return linhas.join("\n");
125
+ }
126
+ /**
127
+ * O changelog: fases concluidas agrupadas por versao, na ordem em que a API devolveu (mais
128
+ * recente primeiro). Grupo sem versao recebe `SEM_VERSAO`. A ordem dos grupos e a da
129
+ * primeira aparicao — versao e rotulo livre, entao nao ha como ordena-la melhor que pelo
130
+ * tempo em que suas fases foram concluidas.
131
+ */
132
+ export function formataChangelog(concluidas) {
133
+ if (concluidas.length === 0)
134
+ return "Nenhuma fase concluída ainda.";
135
+ const grupos = new Map();
136
+ for (const f of concluidas) {
137
+ const chave = f.versao ?? SEM_VERSAO;
138
+ const grupo = grupos.get(chave) ?? [];
139
+ grupo.push(f);
140
+ grupos.set(chave, grupo);
141
+ }
142
+ const linhas = [];
143
+ for (const [versao, fases] of grupos) {
144
+ if (linhas.length > 0)
145
+ linhas.push("");
146
+ linhas.push(`## ${versao}`);
147
+ for (const f of fases) {
148
+ const data = f.concluida_em ? f.concluida_em.slice(0, 10) : "";
149
+ linhas.push(`- ${f.titulo}${data ? ` — ${data}` : ""} [${f.slug}]`);
150
+ }
151
+ }
152
+ return linhas.join("\n");
153
+ }
@@ -12,6 +12,7 @@ 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 { criaFase, editaFase, formataChangelog, formataRoadmap, leFases, } from "../../cli/src/roadmap.js";
15
16
  /**
16
17
  * O mesmo servico, outra porta.
17
18
  *
@@ -283,15 +284,128 @@ Conteúdo vazio é válido e significa apagar o artefato.`,
283
284
  }, async ({ tipo, conteudo }) => {
284
285
  try {
285
286
  const r = await escreveArtefato(raiz, tipo, conteudo);
287
+ // Sem promessa de atraso: a politica nova ja vale para quem LER daqui em diante,
288
+ // inclusive nesta sessao (`ler_artefato` logo em seguida devolve o conteudo novo).
289
+ // O que nao muda sozinho e o que ja foi injetado no contexto pelo hook, no inicio
290
+ // da sessao — e essa e a distincao que a mensagem precisa fazer.
286
291
  return texto(`${r.criou ? "Criado" : "Atualizado"} o ${r.artefato} — ${r.tamanho} caractere(s).` +
287
292
  (tipo === "politica"
288
- ? " A política nova vale a partir da próxima sessão."
293
+ ? " vale para quem ler a partir de agora; o que foi carregado no início desta sessão não muda sozinho."
289
294
  : ""));
290
295
  }
291
296
  catch (erro) {
292
297
  return falha(erro);
293
298
  }
294
299
  });
300
+ // --- roadmap e changelog: o mesmo dado, duas leituras ---
301
+ server.registerTool("ler_roadmap", {
302
+ description: `As fases ABERTAS do projeto: a atual em texto integral (o "Agora" — um foco por vez) e as próximas por título (o "A seguir"). Opcionalmente as ideias não comprometidas.
303
+
304
+ A fase atual já chega no início da sessão, junto da política. Use isto quando precisar ver as próximas com mais detalhe, ou quando o roadmap tiver mudado durante a sessão.
305
+
306
+ Fase concluída NÃO aparece aqui — é changelog (\`ler_changelog\`). Roadmap é opcional: projeto sem fase devolve isso, e não é erro. Se o trabalho tiver fases de verdade e não houver roadmap, proponha criar a primeira com \`criar_fase\`.`,
307
+ inputSchema: z.object({
308
+ incluir_ideias: z
309
+ .boolean()
310
+ .optional()
311
+ .describe("Mostrar também as fases com status `ideia` (não comprometidas). Padrão: não."),
312
+ }),
313
+ }, async ({ incluir_ideias }) => {
314
+ try {
315
+ const fases = await leFases(raiz);
316
+ const visiveis = incluir_ideias ? fases : fases.filter((f) => f.status !== "ideia");
317
+ return texto(formataRoadmap(visiveis));
318
+ }
319
+ catch (erro) {
320
+ return falha(erro);
321
+ }
322
+ });
323
+ server.registerTool("ler_changelog", {
324
+ description: `O que já foi ENTREGUE: as fases concluídas, agrupadas por versão, da mais recente para a mais antiga. Fase concluída sem versão aparece em "${"Sem versão (ainda não lançado)"}" — feita, mas ainda fora de uma versão fechada.
325
+
326
+ Não existe changelog separado do roadmap: isto é a leitura das mesmas fases com \`status: "concluida"\`. Por isso nunca diverge do que foi planejado.
327
+
328
+ Use para responder "o que entregamos na 1.0?" ou "o que já está pronto?". Para o corpo de uma fase específica, o endereço entre colchetes é o \`slug\`.`,
329
+ inputSchema: z.object({
330
+ versao: z
331
+ .string()
332
+ .optional()
333
+ .describe("Filtra por uma versão exata (rótulo livre, como \"1.0.0\" ou \"onda 2\")."),
334
+ }),
335
+ }, async ({ versao }) => {
336
+ try {
337
+ const fases = await leFases(raiz, { status: "concluida", ...(versao ? { versao } : {}) });
338
+ return texto(formataChangelog(fases));
339
+ }
340
+ catch (erro) {
341
+ return falha(erro);
342
+ }
343
+ });
344
+ server.registerTool("criar_fase", {
345
+ description: `Cria uma fase no roadmap. Proponha ao humano antes: o roadmap é a direção do projeto, e uma fase nova é um compromisso.
346
+
347
+ Uma fase é uma etapa com checklist — o checklist vai DENTRO do conteúdo, em markdown (\`- [ ] item\`). A unidade de estado é a fase: marcar um item é editar o texto; concluir a fase é mudar o status.
348
+
349
+ \`status: "concluida"\` na criação é o caminho do trabalho NÃO planejado (um hotfix, um ajuste feito na hora) entrar no changelog: cria a fase já feita. Não invente uma fase "aberta" para algo que já aconteceu.
350
+
351
+ \`versao\` é rótulo livre (\`1.0.0\`, \`onda 2\`, \`lançamento\`) e opcional — pode ser atribuída depois, ao fechar uma versão. O slug (endereço) é derivado do título e fixo: escolha o título pensando nisso.`,
352
+ inputSchema: z.object({
353
+ titulo: z.string().min(2).max(120).describe("Curto e específico: `Autenticação por e-mail`, não `Fase 2`."),
354
+ conteudo: z
355
+ .string()
356
+ .max(50000)
357
+ .optional()
358
+ .describe("Markdown: o que a fase cobre e o checklist (`- [ ] item`). Pode ficar vazio e ser preenchido depois."),
359
+ versao: z
360
+ .string()
361
+ .max(40)
362
+ .optional()
363
+ .describe("Versão prevista ou, se já concluída, a versão em que saiu. Rótulo livre."),
364
+ status: z
365
+ .enum(["ideia", "aberta", "concluida"])
366
+ .optional()
367
+ .describe("Padrão `aberta`. `ideia` = não comprometida, fora do roadmap ativo. `concluida` = já feita (hotfix)."),
368
+ }),
369
+ }, async ({ titulo, conteudo, versao, status }) => {
370
+ try {
371
+ const { fase } = await criaFase(raiz, { titulo, conteudo, versao, status });
372
+ return texto(`Fase \`${fase.slug}\` criada — ordem ${fase.ordem}, ${fase.status}` +
373
+ (fase.versao ? `, versão ${fase.versao}` : "") +
374
+ (fase.status === "concluida" ? ". Já está no changelog." : "."));
375
+ }
376
+ catch (erro) {
377
+ return falha(erro);
378
+ }
379
+ });
380
+ server.registerTool("editar_fase", {
381
+ description: `Edita uma fase pelo \`slug\`: título, conteúdo (o checklist), versão, status ou ordem. Mande só o que muda.
382
+
383
+ CONCLUIR uma fase é isto com \`status: "concluida"\`. Proponha ao humano antes — é ele quem decide que a fase acabou. Ao concluir, a fase sai do roadmap e entra no changelog sozinha; nada precisa ser movido ou reescrito. Reabrir é \`status: "aberta"\`. \`descartada\` tira do roadmap sem entrar no changelog e sem apagar o rastro.
384
+
385
+ 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
+
387
+ \`versao: null\` tira a versão. \`ordem\` reposiciona: a aberta de menor ordem é o "Agora".`,
388
+ inputSchema: z.object({
389
+ slug: z.string().min(1).describe("O endereço da fase, como aparece entre colchetes em `ler_roadmap` e `ler_changelog`."),
390
+ titulo: z.string().min(2).max(120).optional(),
391
+ conteudo: z.string().max(50000).optional().describe("Substitui o conteúdo inteiro — para marcar um item, mande o checklist completo com o item marcado."),
392
+ versao: z.string().max(40).nullable().optional().describe("Rótulo livre; `null` remove."),
393
+ status: z.enum(["ideia", "aberta", "concluida", "descartada"]).optional(),
394
+ ordem: z.number().int().positive().optional(),
395
+ }),
396
+ }, async ({ slug, ...mudancas }) => {
397
+ try {
398
+ const { fase } = await editaFase(raiz, slug, mudancas);
399
+ // A frase de transicao so quando a conclusao foi o PEDIDO — editar o texto de uma
400
+ // fase ja concluida nao a faz "sair do roadmap" de novo.
401
+ return texto(`Fase \`${fase.slug}\` atualizada — ${fase.status}` +
402
+ (fase.versao ? `, versão ${fase.versao}` : "") +
403
+ (mudancas.status === "concluida" ? ". Saiu do roadmap e entrou no changelog." : "."));
404
+ }
405
+ catch (erro) {
406
+ return falha(erro);
407
+ }
408
+ });
295
409
  return server;
296
410
  }
297
411
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dd-harness-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Servidor MCP do dd-harness: o agente consulta e grava memoria como ferramenta, sem passar por arquivo.",
6
6
  "license": "UNLICENSED",