@fontdata/ai-harness 3.3.1 → 4.0.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.
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "name": "fontdata",
13
13
  "source": "./",
14
- "description": "19 slash commands para conduzir spec, contexto, harness, revisão e manutenção de projetos com Claude Code."
14
+ "description": "20 slash commands para conduzir spec, contexto, harness, revisão e manutenção de projetos com Claude Code."
15
15
  }
16
16
  ]
17
17
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "fontdata",
3
- "version": "3.3.1",
3
+ "version": "4.0.0",
4
4
  "mcpServers": {
5
- "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.3.1", "banco-dados-mcp"] }
5
+ "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@4.0.0", "banco-dados-mcp"] }
6
6
  },
7
7
  "description": "Harness de engenharia com IA da Fontdata para Claude Code",
8
8
  "author": { "name": "Fontdata" },
package/README.md CHANGED
@@ -32,7 +32,7 @@ carregado como plugin **só daquela sessão** — nenhum arquivo do harness é e
32
32
  # npm / npx
33
33
  npx @fontdata/ai-harness harness
34
34
  npx @fontdata/ai-harness resolver-bug "login 500 no refresh token"
35
- npx @fontdata/ai-harness versao minor
35
+ npx @fontdata/ai-harness run versao minor
36
36
 
37
37
  # yarn
38
38
  yarn dlx @fontdata/ai-harness harness
@@ -41,7 +41,7 @@ yarn dlx @fontdata/ai-harness harness
41
41
  pnpm dlx @fontdata/ai-harness harness
42
42
  ```
43
43
 
44
- Durante a sessão, todos os 19 comandos ficam disponíveis com o prefixo do plugin — `/fontdata:nova-tarefa`,
44
+ Durante a sessão, todos os 20 comandos ficam disponíveis com o prefixo do plugin — `/fontdata:nova-tarefa`,
45
45
  `/fontdata:resolver-bug` etc. —, junto com os hooks e os agents do harness.
46
46
  É isso que faz o `/harness`, que encadeia 4 comandos, funcionar sem instalação prévia.
47
47
 
@@ -167,8 +167,27 @@ projeto, cadastro, limites do filtro, produção, mascaramento e dúvidas freque
167
167
  | `banco-dados <subcomando>` | Conexões de banco somente leitura: `adicionar`, `listar`, `testar`, `usar`, `remover` |
168
168
  | `banco-dados-mcp` | Servidor MCP de leitura do banco, iniciado pelo Claude Code |
169
169
  | `comentarios [caminho]` | Instala o hook do padrão de comentários com a catraca do legado (`--yes`) |
170
+ | `versao detectar\|calcular\|aplicar` | Fontes de versão do projeto, próxima versão e gravação nos arquivos (veja abaixo) |
170
171
  | `list` | Lista o catálogo |
171
172
 
173
+ ### Subcomando `versao`
174
+
175
+ `npx @fontdata/ai-harness versao detectar|calcular|aplicar` é a parte determinística do `/versao`, para qualquer
176
+ stack. Não faz commit, tag nem push.
177
+
178
+ - `detectar` lista as fontes da versão (arquivo, extrator, linha e valor), o modelo da tag e as divergências.
179
+ - `calcular` infere o próximo bump pelos commits desde a última tag (nunca major) ou aplica `major|minor|patch|build`.
180
+ - `aplicar <versao>` grava a versão em todas as fontes; com `--dry-run` só mostra o diff.
181
+ - `--json` devolve a saída para máquinas (é o que o `/versao` e o `/publicar-release` leem); `--app <nome>` escolhe
182
+ o app num monorepo.
183
+
184
+ Onde a versão mora, o modelo da tag (`v{versao}`, `{app}-v{versao}`…), os segmentos, os changelogs, o que nunca
185
+ tocar e o comando de validação ficam no `.ai-harness/versao.json`, opcional e versionado. Sem ele, a CLI detecta as
186
+ fontes por stack e propõe o arquivo. O formato está em [`docs/contratos/VERSAO.md`](docs/contratos/VERSAO.md).
187
+
188
+ Para abrir o fluxo `/versao` no Claude Code, use `npx @fontdata/ai-harness run versao minor`: `versao` é subcomando
189
+ do CLI.
190
+
172
191
  ## Modelo de arquivos
173
192
 
174
193
  | Arquivo | Papel |
@@ -247,6 +266,7 @@ também acionado pelo modelo quando o pedido combina.
247
266
  ### Utilitário
248
267
  - `/git-commit-push` ⭐ — gera a mensagem (PT-BR, Conventional Commits) e faz add, commit e push
249
268
  - `/versao` ⭐ — detecta o versionamento da stack e aplica bump e changelog, com aprovação e sem push
269
+ - `/publicar-release` — depois do `/versao`, cria a tag (exceto com auto-tag), faz o push após confirmação e cria o GitHub Release
250
270
  - `/revisar-pr` — revisa um PR ou o diff atual, classifica os achados e, se pedido, organiza as correções num trabalho
251
271
 
252
272
  ### Setup guiado
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ const RESERVED = new Set([
20
20
  'memoria',
21
21
  'banco-dados',
22
22
  'banco-dados-mcp',
23
+ 'versao',
23
24
  'list',
24
25
  'run',
25
26
  'help',
@@ -66,6 +67,7 @@ Gerenciar:
66
67
  --antes-de <dias> muda o corte; --remover apaga do disco os que o git recupera
67
68
  banco-dados <subcomando> Conexões de banco somente leitura: adicionar, listar, testar, usar, remover
68
69
  banco-dados-mcp Servidor MCP de leitura do banco, usado pelo Claude Code (stdio)
70
+ versao detectar|calcular|aplicar Fontes de versão, próxima versão e gravação (--app, --json; aplicar --dry-run)
69
71
  list Lista todos os comandos disponíveis no catálogo
70
72
 
71
73
  Init sem perguntas (CI):
@@ -177,6 +179,10 @@ async function main() {
177
179
  const { runUpdateCommand } = await import('./update/update.js');
178
180
  process.exit(runUpdateCommand(argv.slice(1)));
179
181
  }
182
+ if (cmd === 'versao') {
183
+ const { runVersaoCommand } = await import('./versao/command.js');
184
+ process.exit(runVersaoCommand(argv.slice(1)));
185
+ }
180
186
  if (cmd === 'run') {
181
187
  const id = argv[1];
182
188
  if (!id) {
@@ -9,6 +9,7 @@ import { CONFIG_PATH, readConfig } from '../config/config.js';
9
9
  import { ARCHIVE_DIR_NAME, ARTIFACT_TYPES, listWorkFiles, readArtifact, validateAllWorks, WORKS_DIR } from '../contratos/artefatos.js';
10
10
  import { validateEventLog } from '../contratos/eventos.js';
11
11
  import { hashContent, LOCK_PATH, LOCK_VERSION } from '../install/lock.js';
12
+ import { itemSources } from '../install/plan.js';
12
13
  import { SETTINGS_PATH } from '../install/settings.js';
13
14
  import { planArchive } from '../trabalhos/arquivar.js';
14
15
  import { ARCHIVE_DEFAULT_DAYS } from '../trabalhos/command.js';
@@ -102,7 +103,9 @@ function checkLock(root) {
102
103
  }
103
104
  function checkFiles(root, lock) {
104
105
  const findings = [];
106
+ const catalog = loadCatalog();
105
107
  for (const item of lock.itens) {
108
+ const sourceByPath = new Map((itemSources(catalog, item.tipo, item.id) ?? []).map((entry) => [entry.path, entry.source]));
106
109
  for (const entry of item.arquivos) {
107
110
  const file = join(root, entry.caminho);
108
111
  if (!existsSync(file)) {
@@ -120,6 +123,15 @@ function checkFiles(root, lock) {
120
123
  mensagem: `Arquivo alterado localmente desde a instalação: ${entry.caminho}.`,
121
124
  correcao: 'Se a alteração é intencional, mantenha: atualizações não sobrescrevem arquivos alterados sem confirmação.',
122
125
  });
126
+ const source = sourceByPath.get(entry.caminho);
127
+ if (source && existsSync(source) && hashContent(readFileSync(source)) !== entry.sha256) {
128
+ findings.push({
129
+ id: 'comando-customizado-defasado',
130
+ severidade: 'aviso',
131
+ mensagem: `Arquivo customizado preso numa versão antiga do template, que mudou desde a instalação: ${entry.caminho}.`,
132
+ correcao: `Compare ${entry.caminho} com o template atual do pacote; se não houver customização a manter, rode \`npx @fontdata/ai-harness update --forcar\`.`,
133
+ });
134
+ }
123
135
  }
124
136
  }
125
137
  }
@@ -103,7 +103,9 @@ export function checkUpdate(root, catalog = loadCatalog(), legacy, home, { semBa
103
103
  for (const { path, source } of sources) {
104
104
  if (trackedPaths.has(path))
105
105
  continue;
106
- changes.push({ tipo: existsSync(join(root, path)) ? 'conflito' : 'novo', caminho: path, item: key, source });
106
+ const target = join(root, path);
107
+ const same = existsSync(target) && hashContent(readFileSync(target)) === hashContent(readFileSync(source));
108
+ changes.push({ tipo: !existsSync(target) || same ? 'novo' : 'conflito', caminho: path, item: key, source });
107
109
  }
108
110
  }
109
111
  // Itens que o lock não tem. Arquivo igual ao do pacote só entra no lock; `protect` mantém o do projeto mesmo com --forcar.
@@ -0,0 +1,424 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { EOL } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { suggestFlag } from '../install/flags.js';
5
+ import { acharRaiz } from '../raiz.js';
6
+ import { carregarVersaoConfig } from './config.js';
7
+ import { detectarFontes, extratorParaTexto } from './detectar.js';
8
+ import { diffLinhas } from './diff.js';
9
+ import { criarExtrator } from './extratores/index.js';
10
+ import { formatarVersao, incrementar, parseVersao } from './formato.js';
11
+ import { acharTagBase, classificarBump, listarCommits } from './git.js';
12
+ const SUBCOMANDOS = ['detectar', 'calcular', 'aplicar'];
13
+ const INCREMENTOS = ['major', 'minor', 'patch', 'build'];
14
+ const STATUS_IGNORADA = 'ignorada (naoTocar)';
15
+ const STATUS_CHANGELOG = 'changelog, gravado pelo /versao';
16
+ function parseOpcoes(sub, argv) {
17
+ const booleanas = sub === 'aplicar' ? ['--json', '--dry-run'] : ['--json'];
18
+ const opcoes = { json: false, dryRun: false, posicionais: [] };
19
+ for (let i = 0; i < argv.length; i++) {
20
+ const token = argv[i];
21
+ if (token === '--app') {
22
+ const valor = argv[++i];
23
+ if (valor === undefined || valor.startsWith('--'))
24
+ throw new Error('--app precisa do nome do app.');
25
+ opcoes.app = valor;
26
+ }
27
+ else if (booleanas.includes(token)) {
28
+ if (token === '--json')
29
+ opcoes.json = true;
30
+ else
31
+ opcoes.dryRun = true;
32
+ }
33
+ else if (token.startsWith('--')) {
34
+ const sugestao = suggestFlag(token, ['--app', ...booleanas]);
35
+ throw new Error(`Flag desconhecida: ${token}.${sugestao ? ` Você quis dizer ${sugestao}?` : ''}`);
36
+ }
37
+ else {
38
+ opcoes.posicionais.push(token);
39
+ }
40
+ }
41
+ const maximo = sub === 'detectar' ? 0 : 1;
42
+ if (opcoes.posicionais.length > maximo)
43
+ throw new Error(`Argumento inesperado: ${opcoes.posicionais[maximo]}`);
44
+ if (sub === 'aplicar' && opcoes.posicionais.length === 0)
45
+ throw new Error('Informe a versão: versao aplicar <versao> [--dry-run].');
46
+ return opcoes;
47
+ }
48
+ function normalizarCaminho(caminho) {
49
+ return caminho.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '');
50
+ }
51
+ /** Entrada de `naoTocar` que é caminho bloqueia o arquivo; nome de campo bloqueia o `json:` que termina nele. */
52
+ function bloqueada(fonte, naoTocar) {
53
+ const caminho = normalizarCaminho(fonte.caminho);
54
+ return naoTocar.some((entrada) => {
55
+ if (normalizarCaminho(entrada) === caminho)
56
+ return true;
57
+ if (fonte.extrator.tipo !== 'json')
58
+ return false;
59
+ const campo = entrada.split('.');
60
+ return campo.length <= fonte.extrator.caminho.length && fonte.extrator.caminho.slice(-campo.length).join('\u0000') === campo.join('\u0000');
61
+ });
62
+ }
63
+ function fonteJson(fonte, naoTocar) {
64
+ return {
65
+ caminho: fonte.caminho,
66
+ extrator: extratorParaTexto(fonte.extrator),
67
+ formato: fonte.formato ?? null,
68
+ valores: [...new Set(fonte.ocorrencias.map((o) => o.valor))],
69
+ ocorrencias: fonte.ocorrencias.map((o) => ({ linha: o.linha, valor: o.valor, grupo: o.grupo ?? null })),
70
+ somenteLeitura: fonte.extrator.tipo === 'versoes-txt',
71
+ naoTocar: bloqueada(fonte, naoTocar),
72
+ };
73
+ }
74
+ function mensagemSemFonte(d) {
75
+ const apps = Object.keys(d.apps);
76
+ if (d.app === null && apps.length > 0) {
77
+ return `nenhuma fonte de versão na raiz; informe --app <nome> (apps: ${apps.join(', ')})`;
78
+ }
79
+ return d.origem === 'config'
80
+ ? 'nenhuma fonte de versão no .ai-harness/versao.json; declare os arquivos em "arquivos" (ou nos apps)'
81
+ : 'nenhuma fonte de versão detectada; crie o .ai-harness/versao.json declarando os arquivos (docs/contratos/VERSAO.md)';
82
+ }
83
+ function exigirFontes(d) {
84
+ if (d.fontes.length === 0)
85
+ throw new Error(mensagemSemFonte(d));
86
+ }
87
+ function tentarParse(texto, formato) {
88
+ try {
89
+ return parseVersao(texto, formato);
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ function segmentosDe(texto, formato) {
96
+ const comFormato = formato ? tentarParse(texto, formato) : null;
97
+ if (comFormato)
98
+ return comFormato;
99
+ if (/^\d+(?:\.\d+)*$/.test(texto))
100
+ return parseVersao(texto);
101
+ throw new Error(`versão inválida: "${texto}"; use números separados por ponto${formato ? ` ou o formato ${formato}` : ''}`);
102
+ }
103
+ function comparar(a, b) {
104
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
105
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
106
+ if (d !== 0)
107
+ return d;
108
+ }
109
+ return 0;
110
+ }
111
+ function tentarBase(raiz, d) {
112
+ if (d.tag.includes('{app}') && d.app === null)
113
+ return null;
114
+ try {
115
+ return acharTagBase(raiz, d.tag, d.app ?? undefined);
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function baseJson(base) {
122
+ return base ? { tag: base.tag, versao: base.versao, ref: base.base, primeiroRelease: base.primeiroRelease } : null;
123
+ }
124
+ function montarTag(modelo, versao, app) {
125
+ if (versao === null || (modelo.includes('{app}') && app === null))
126
+ return null;
127
+ return modelo.replaceAll('{versao}', versao).replaceAll('{app}', app ?? '');
128
+ }
129
+ function detectar({ raiz, deteccao: d, config }) {
130
+ const naoTocar = config?.naoTocar ?? [];
131
+ const problemas = [...d.problemas];
132
+ if (d.fontes.length === 0)
133
+ problemas.unshift(mensagemSemFonte(d));
134
+ return {
135
+ ok: true,
136
+ comando: 'detectar',
137
+ raiz,
138
+ origem: d.origem,
139
+ app: d.app,
140
+ versaoAtual: d.versaoAtual,
141
+ tag: d.tag,
142
+ base: baseJson(tentarBase(raiz, d)),
143
+ segmentos: d.segmentos,
144
+ formato: d.formato,
145
+ fontes: d.fontes.map((f) => fonteJson(f, naoTocar)),
146
+ divergencias: d.divergencias,
147
+ apps: Object.fromEntries(Object.entries(d.apps).map(([nome, a]) => [
148
+ nome,
149
+ { caminhos: a.caminhos, versaoAtual: a.versaoAtual, tag: a.tag, fontes: a.fontes.map((f) => fonteJson(f, naoTocar)), divergencias: a.divergencias },
150
+ ])),
151
+ naoTocar,
152
+ problemas,
153
+ versaoJson: d.versaoJson,
154
+ };
155
+ }
156
+ function calcular({ raiz, deteccao: d, config, opcoes }) {
157
+ exigirFontes(d);
158
+ if (d.versaoAtual === null) {
159
+ throw new Error(`versão atual não encontrada nas fontes${d.problemas.length ? `:${EOL}- ${d.problemas.join(`${EOL}- `)}` : ''}`);
160
+ }
161
+ const formato = d.formato ?? undefined;
162
+ const atual = segmentosDe(d.versaoAtual, formato);
163
+ const caminhos = d.app !== null ? (d.apps[d.app]?.caminhos ?? null) : null;
164
+ const avisos = [];
165
+ let base = null;
166
+ let commits = [];
167
+ let erroGit = null;
168
+ try {
169
+ base = acharTagBase(raiz, d.tag, d.app ?? undefined);
170
+ commits = listarCommits(raiz, base.base, caminhos ?? undefined, base.primeiroRelease);
171
+ }
172
+ catch (erro) {
173
+ erroGit = erro.message;
174
+ }
175
+ const argumento = opcoes.posicionais[0];
176
+ let bump = null;
177
+ let proximos = null;
178
+ if (argumento !== undefined) {
179
+ if (INCREMENTOS.includes(argumento))
180
+ bump = { tipo: argumento, origem: 'argumento' };
181
+ else {
182
+ proximos = segmentosDe(argumento, formato);
183
+ bump = { tipo: 'versao', origem: 'argumento' };
184
+ }
185
+ }
186
+ else if (config && config.bump.padrao !== 'auto') {
187
+ bump = { tipo: config.bump.padrao, origem: 'config' };
188
+ }
189
+ else {
190
+ if (erroGit !== null)
191
+ throw new Error(`não foi possível ler os commits para inferir o bump: ${erroGit}`);
192
+ const inferido = classificarBump(commits);
193
+ avisos.push(...inferido.avisos);
194
+ if (inferido.tipo === 'nenhum') {
195
+ const desde = base?.tag ?? 'o primeiro commit';
196
+ avisos.push(`nenhum commit com tipo semântico (feat, fix, chore...) desde ${desde}${caminhos ? ` em ${caminhos.join(', ')}` : ''}; ` +
197
+ 'nada a versionar. Informe o bump (major|minor|patch|build) ou a versão.');
198
+ }
199
+ else
200
+ bump = { tipo: inferido.tipo, origem: 'inferido' };
201
+ }
202
+ if (erroGit !== null)
203
+ avisos.push(`commits não lidos: ${erroGit}`);
204
+ if (bump && bump.tipo !== 'versao') {
205
+ proximos = incrementar(atual, bump.tipo);
206
+ while (d.segmentos !== null && proximos.length < d.segmentos)
207
+ proximos.push(0);
208
+ }
209
+ let proxima = null;
210
+ if (proximos) {
211
+ proxima = formato ? formatarVersao(proximos, formato) : bump?.tipo === 'versao' ? argumento : formatarVersao(proximos);
212
+ if (comparar(proximos, atual) <= 0)
213
+ avisos.push(`a próxima versão ${proxima} não é maior que a atual ${d.versaoAtual}`);
214
+ }
215
+ return {
216
+ ok: true,
217
+ comando: 'calcular',
218
+ raiz,
219
+ app: d.app,
220
+ versaoAtual: d.versaoAtual,
221
+ proxima,
222
+ tag: d.tag,
223
+ tagProxima: montarTag(d.tag, proxima, d.app),
224
+ bump,
225
+ base: baseJson(base),
226
+ caminhos,
227
+ commits,
228
+ divergencias: d.divergencias,
229
+ avisos,
230
+ problemas: d.problemas,
231
+ };
232
+ }
233
+ function textoParaFonte(fonte, segmentos, versao, formatoRaiz) {
234
+ if (fonte.extrator.tipo === 'dproj')
235
+ return formatarVersao(segmentos);
236
+ const formato = fonte.formato ?? fonte.padding?.map((p) => `{${p}}`).join('.') ?? formatoRaiz ?? undefined;
237
+ return formato ? formatarVersao(segmentos, formato) : versao;
238
+ }
239
+ function statusDoArquivo(fontes, mudou) {
240
+ if (mudou)
241
+ return 'alterado';
242
+ if (fontes.every((f) => f.status === STATUS_IGNORADA))
243
+ return 'ignorado (naoTocar)';
244
+ if (fontes.every((f) => f.status === STATUS_CHANGELOG || f.status === STATUS_IGNORADA))
245
+ return STATUS_CHANGELOG;
246
+ return 'sem alteração';
247
+ }
248
+ function aplicar({ raiz, deteccao: d, config, opcoes }) {
249
+ exigirFontes(d);
250
+ const versao = opcoes.posicionais[0];
251
+ const segmentos = segmentosDe(versao, d.formato ?? undefined);
252
+ const naoTocar = config?.naoTocar ?? [];
253
+ const porArquivo = new Map();
254
+ for (const f of d.fontes)
255
+ porArquivo.set(f.caminho, [...(porArquivo.get(f.caminho) ?? []), f]);
256
+ const erros = [];
257
+ const planos = [];
258
+ for (const [caminho, fontes] of porArquivo) {
259
+ const absoluto = join(raiz, caminho);
260
+ const original = existsSync(absoluto) ? readFileSync(absoluto) : null;
261
+ let atual = original;
262
+ const aplicadas = [];
263
+ for (const fonte of fontes) {
264
+ const extrator = extratorParaTexto(fonte.extrator);
265
+ if (bloqueada(fonte, naoTocar)) {
266
+ aplicadas.push({ extrator, status: STATUS_IGNORADA });
267
+ continue;
268
+ }
269
+ if (fonte.extrator.tipo === 'versoes-txt') {
270
+ aplicadas.push({ extrator, status: STATUS_CHANGELOG });
271
+ continue;
272
+ }
273
+ try {
274
+ if (atual === null)
275
+ throw new Error('arquivo não encontrado');
276
+ const texto = textoParaFonte(fonte, segmentos, versao, d.formato);
277
+ const novo = criarExtrator(fonte.extrator).gravar(caminho, atual, texto);
278
+ aplicadas.push({ extrator, status: novo.equals(atual) ? 'sem alteração' : 'alterada', versao: texto });
279
+ atual = novo;
280
+ }
281
+ catch (erro) {
282
+ const mensagem = erro.message;
283
+ erros.push(`${caminho} (${extrator}): ${mensagem}`);
284
+ aplicadas.push({ extrator, status: 'erro', mensagem });
285
+ }
286
+ }
287
+ const mudou = original !== null && atual !== null && !atual.equals(original);
288
+ planos.push({
289
+ arquivo: { caminho, status: statusDoArquivo(aplicadas, mudou), gravado: false, fontes: aplicadas, diff: mudou ? diffLinhas(original, atual) : [] },
290
+ novo: mudou ? atual : null,
291
+ });
292
+ }
293
+ if (erros.length > 0)
294
+ throw new Error(`nada foi gravado:${EOL}- ${erros.join(`${EOL}- `)}`);
295
+ if (!opcoes.dryRun) {
296
+ for (const plano of planos) {
297
+ if (!plano.novo)
298
+ continue;
299
+ writeFileSync(join(raiz, plano.arquivo.caminho), plano.novo);
300
+ plano.arquivo.gravado = true;
301
+ }
302
+ }
303
+ return {
304
+ ok: true,
305
+ comando: 'aplicar',
306
+ raiz,
307
+ app: d.app,
308
+ versao,
309
+ dryRun: opcoes.dryRun,
310
+ arquivos: planos.map((p) => p.arquivo),
311
+ divergencias: d.divergencias,
312
+ problemas: d.problemas,
313
+ };
314
+ }
315
+ function linhasDeFonte(f) {
316
+ const marcas = [f.somenteLeitura ? 'só leitura' : '', f.naoTocar ? 'naoTocar' : ''].filter(Boolean);
317
+ return ` ${f.caminho} (${f.extrator}): ${f.valores.join(', ') || '—'}${marcas.length ? ` [${marcas.join(', ')}]` : ''}`;
318
+ }
319
+ function linhasDeLista(titulo, itens) {
320
+ return itens.length ? [`${titulo}:`, ...itens.map((i) => ` - ${i}`)] : [];
321
+ }
322
+ function divergenciasTexto(divergencias) {
323
+ return linhasDeLista('Divergências', divergencias.map((d) => d.mensagem));
324
+ }
325
+ function textoDetectar(s) {
326
+ const base = s.base ? ` (base: ${s.base.tag ?? 'nenhuma tag; primeiro release'})` : '';
327
+ const linhas = [
328
+ `Raiz: ${s.raiz}`,
329
+ `Origem: ${s.origem === 'config' ? '.ai-harness/versao.json' : 'detecção por stack (sem .ai-harness/versao.json)'}`,
330
+ ...(s.app ? [`App: ${s.app}`] : []),
331
+ `Versão atual: ${s.versaoAtual ?? '—'}`,
332
+ `Tag: ${s.tag}${base}`,
333
+ ];
334
+ if (s.fontes.length)
335
+ linhas.push('Fontes:', ...s.fontes.map(linhasDeFonte));
336
+ for (const [nome, a] of Object.entries(s.apps)) {
337
+ linhas.push(`App ${nome} (${a.caminhos.join(', ')}): versão ${a.versaoAtual ?? '—'}, tag ${a.tag}`, ...a.fontes.map((f) => ` ${linhasDeFonte(f)}`));
338
+ }
339
+ linhas.push(...divergenciasTexto(s.divergencias), ...linhasDeLista('Problemas', s.problemas));
340
+ if (s.versaoJson)
341
+ linhas.push('versao.json proposto (.ai-harness/versao.json):', JSON.stringify(s.versaoJson, null, 2));
342
+ return linhas.join(EOL);
343
+ }
344
+ function textoCalcular(s) {
345
+ const bump = s.bump ? `${s.bump.tipo} (${s.bump.origem})` : 'nenhum';
346
+ const base = s.base ? (s.base.tag ?? `primeiro release (desde ${s.base.ref.slice(0, 7)})`) : '—';
347
+ return [
348
+ ...(s.app ? [`App: ${s.app}`] : []),
349
+ `Versão atual: ${s.versaoAtual}`,
350
+ `Base: ${base}`,
351
+ `Commits considerados (${s.commits.length})${s.caminhos ? ` em ${s.caminhos.join(', ')}` : ''}:`,
352
+ ...s.commits.map((c) => ` ${c.hash.slice(0, 7)} ${c.titulo}`),
353
+ `Bump: ${bump}`,
354
+ `Próxima versão: ${s.proxima ?? '— (nada a versionar)'}${s.tagProxima ? ` (tag ${s.tagProxima})` : ''}`,
355
+ ...divergenciasTexto(s.divergencias),
356
+ ...linhasDeLista('Avisos', s.avisos),
357
+ ...linhasDeLista('Problemas', s.problemas),
358
+ ].join(EOL);
359
+ }
360
+ function textoAplicar(s) {
361
+ const linhas = [`Versão ${s.versao}${s.dryRun ? ' (simulação: nada foi gravado)' : ''}`];
362
+ for (const a of s.arquivos) {
363
+ linhas.push(`${a.caminho}: ${a.status}${a.gravado ? ', gravado' : ''}`);
364
+ for (const f of a.fontes)
365
+ if (f.status === STATUS_IGNORADA && a.status !== 'ignorado (naoTocar)')
366
+ linhas.push(` ${f.extrator}: ${f.status}`);
367
+ for (const d of a.diff) {
368
+ if (d.antes !== null)
369
+ linhas.push(` - ${d.linha}: ${d.antes}`);
370
+ if (d.depois !== null)
371
+ linhas.push(` + ${d.linha}: ${d.depois}`);
372
+ }
373
+ }
374
+ linhas.push(...divergenciasTexto(s.divergencias), ...linhasDeLista('Problemas', s.problemas));
375
+ return linhas.join(EOL);
376
+ }
377
+ function erroDeUso(argv) {
378
+ const sub = argv[0];
379
+ return [
380
+ sub === undefined ? 'Informe o subcomando do versao.' : `Subcomando desconhecido: versao ${sub}.`,
381
+ 'Uso: versao detectar|calcular|aplicar [--app <nome>] [--json] (aplicar aceita --dry-run).',
382
+ `Para abrir o /versao no Claude Code, use: npx @fontdata/ai-harness run versao${argv.length ? ` ${argv.join(' ')}` : ''}`,
383
+ ].join(EOL);
384
+ }
385
+ /** `versao detectar|calcular|aplicar`; devolve o código de saída. Não faz commit, tag nem push. */
386
+ export function runVersaoCommand(argv) {
387
+ const sub = argv[0];
388
+ if (!SUBCOMANDOS.includes(sub)) {
389
+ console.error(erroDeUso(argv));
390
+ return 1;
391
+ }
392
+ const subcomando = sub;
393
+ const json = argv.includes('--json');
394
+ try {
395
+ const opcoes = parseOpcoes(subcomando, argv.slice(1));
396
+ const raiz = acharRaiz();
397
+ const config = carregarVersaoConfig(raiz);
398
+ const contexto = { raiz, config, deteccao: detectarFontes(raiz, config, opcoes.app), opcoes };
399
+ let saida;
400
+ let texto;
401
+ if (subcomando === 'detectar') {
402
+ const s = detectar(contexto);
403
+ [saida, texto] = [s, textoDetectar(s)];
404
+ }
405
+ else if (subcomando === 'calcular') {
406
+ const s = calcular(contexto);
407
+ [saida, texto] = [s, textoCalcular(s)];
408
+ }
409
+ else {
410
+ const s = aplicar(contexto);
411
+ [saida, texto] = [s, textoAplicar(s)];
412
+ }
413
+ console.log(json ? JSON.stringify(saida, null, 2) : texto);
414
+ return 0;
415
+ }
416
+ catch (erro) {
417
+ const mensagem = erro instanceof Error ? erro.message : String(erro);
418
+ if (json)
419
+ console.log(JSON.stringify({ ok: false, comando: subcomando, erro: mensagem }, null, 2));
420
+ else
421
+ console.error(mensagem);
422
+ return 1;
423
+ }
424
+ }