@fontdata/ai-harness 2.6.0 → 3.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.
@@ -1,6 +1,8 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { lerEnvSettingsLocal, lerVariavel, SETTINGS_LOCAL_PATH } from '../banco-dados/ambiente.js';
5
+ import { caminhoCadastro, lerCadastro } from '../banco-dados/cadastro.js';
4
6
  import { allCommands, loadCatalog } from '../catalog.js';
5
7
  import { CONFIG_PATH, readConfig } from '../config/config.js';
6
8
  import { readArtifact, validateAllWorks, WORKS_DIR } from '../contratos/artefatos.js';
@@ -396,6 +398,108 @@ function checkConfig(problems) {
396
398
  correcao: `Corrija o ${CONFIG_PATH}; enquanto isso, valem os padrões (segurança em bloqueio, demais hooks em aviso).`,
397
399
  }));
398
400
  }
401
+ const LINHAS_IGNORE_SETTINGS_LOCAL = new Set(['.claude/settings.local.json', '.claude/', '.claude', 'settings.local.json']);
402
+ function settingsLocalIgnorado(root, env) {
403
+ const opcoes = { cwd: root, env: { ...process.env, ...env }, stdio: 'ignore', timeout: 5000 };
404
+ const repositorio = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], opcoes);
405
+ if (!repositorio.error && repositorio.status === 0) {
406
+ return spawnSync('git', ['check-ignore', '-q', SETTINGS_LOCAL_PATH], opcoes).status === 0;
407
+ }
408
+ const gitignore = join(root, '.gitignore');
409
+ if (!existsSync(gitignore))
410
+ return false;
411
+ return readFileSync(gitignore, 'utf8')
412
+ .split(/\r?\n/)
413
+ .map((linha) => linha.trim().replace(/^\//, '').replace(/^\*\*\//, ''))
414
+ .some((linha) => LINHAS_IGNORE_SETTINGS_LOCAL.has(linha));
415
+ }
416
+ function checkBancoProjeto(root, config, env) {
417
+ const conexoes = config.banco_dados.conexoes.flatMap((nome) => config.banco_dados.projeto[nome] ?? []);
418
+ if (!conexoes.length)
419
+ return [];
420
+ const findings = [];
421
+ for (const { nome, dsn_env } of conexoes) {
422
+ if (lerVariavel(dsn_env, root, env))
423
+ continue;
424
+ findings.push({
425
+ id: 'banco-variavel-ausente',
426
+ severidade: 'aviso',
427
+ mensagem: `A conexão ${nome}, declarada em banco_dados.conexoes de ${join(root, CONFIG_PATH)}, lê o DSN da variável ${dsn_env}, que não está definida no ambiente, em ${SETTINGS_LOCAL_PATH} nem no .env.`,
428
+ correcao: `Defina ${dsn_env} com \`npx @fontdata/ai-harness banco-dados adicionar ${nome} --projeto\` ou no ambiente do terminal.`,
429
+ });
430
+ }
431
+ const envLocal = lerEnvSettingsLocal(root);
432
+ const variaveis = [...new Set(conexoes.map(({ dsn_env }) => dsn_env).filter((variavel) => envLocal[variavel]))];
433
+ if (!variaveis.length)
434
+ return findings;
435
+ const caminho = join(root, SETTINGS_LOCAL_PATH);
436
+ const lista = variaveis.join(', ');
437
+ if (!settingsLocalIgnorado(root, env)) {
438
+ findings.push({
439
+ id: 'banco-settings-local-versionado',
440
+ severidade: 'aviso',
441
+ mensagem: `O ${caminho} guarda o DSN de ${lista} e não está ignorado pelo git; a senha pode ir para o repositório.`,
442
+ correcao: `Acrescente ao .gitignore da raiz a linha \`.claude/settings.local.json\`. Se o arquivo já foi commitado, rode também \`git rm --cached .claude/settings.local.json\`.`,
443
+ });
444
+ }
445
+ const modo = process.platform !== 'win32' ? statSync(caminho).mode & 0o777 : 0;
446
+ if (modo & 0o077) {
447
+ findings.push({
448
+ id: 'banco-settings-local-permissao',
449
+ severidade: 'aviso',
450
+ mensagem: `O ${caminho} guarda o DSN de ${lista} e pode ser lido por outros usuários desta máquina (modo ${modo.toString(8)}).`,
451
+ correcao: `Restrinja ao seu usuário: \`chmod 600 ${caminho}\`.`,
452
+ });
453
+ }
454
+ return findings;
455
+ }
456
+ /** Confere o cadastro desta máquina e as variáveis das conexões do projeto contra `banco_dados.conexoes`, sem abrir conexão. */
457
+ export function checkBanco(root, config, env = process.env) {
458
+ if (!config.banco_dados.conexoes.length)
459
+ return [];
460
+ const findings = checkBancoProjeto(root, config, env);
461
+ const doCadastro = config.banco_dados.conexoes.filter((nome) => !(nome in config.banco_dados.projeto));
462
+ if (!doCadastro.length)
463
+ return findings;
464
+ const caminho = caminhoCadastro(env);
465
+ const modo = process.platform !== 'win32' && existsSync(caminho) ? statSync(caminho).mode & 0o777 : 0;
466
+ if (modo & 0o077) {
467
+ findings.push({
468
+ id: 'banco-cadastro-permissao',
469
+ severidade: 'aviso',
470
+ mensagem: `O cadastro de conexões ${caminho} pode ser lido por outros usuários desta máquina (modo ${modo.toString(8)}).`,
471
+ correcao: `Restrinja ao seu usuário: \`chmod 600 ${caminho}\`.`,
472
+ });
473
+ }
474
+ const { conexoes, problemas } = lerCadastro(caminho);
475
+ for (const mensagem of problemas) {
476
+ findings.push({
477
+ id: 'banco-cadastro-invalido',
478
+ severidade: 'aviso',
479
+ mensagem,
480
+ correcao: `Corrija o JSON de ${caminho} (formato {"versao": 1, "conexoes": {...}}) ou recadastre as conexões.`,
481
+ });
482
+ }
483
+ if (problemas.length)
484
+ return findings;
485
+ for (const nome of doCadastro.filter((nome) => !(nome in conexoes))) {
486
+ findings.push({
487
+ id: 'banco-conexao-ausente',
488
+ severidade: 'aviso',
489
+ mensagem: `A conexão ${nome}, citada em banco_dados.conexoes de ${join(root, CONFIG_PATH)}, não está no cadastro desta máquina (${caminho}).`,
490
+ correcao: `Cadastre-a com \`npx @fontdata/ai-harness banco-dados adicionar ${nome}\`.`,
491
+ });
492
+ }
493
+ for (const nome of doCadastro.filter((nome) => conexoes[nome]?.mascaramento === false)) {
494
+ findings.push({
495
+ id: 'banco-mascaramento-desligado',
496
+ severidade: 'aviso',
497
+ mensagem: `A conexão ${nome}, usada por este projeto, está com o mascaramento LGPD desligado no cadastro desta máquina (${caminho}).`,
498
+ correcao: `Os dados pessoais da conexão ${nome} chegam sem máscara ao Claude. Se não for de propósito, remova "mascaramento": false dela em ${caminho}.`,
499
+ });
500
+ }
501
+ return findings;
502
+ }
399
503
  /** Diagnostica a instalação do harness no projeto; nunca altera arquivos. */
400
504
  export function runDoctor(root, options = {}) {
401
505
  const { findings: lockFindings, lock } = checkLock(root);
@@ -410,6 +514,7 @@ export function runDoctor(root, options = {}) {
410
514
  ...checkSettings(root),
411
515
  ...checkGitIgnore(root),
412
516
  ...checkConfig(configProblems),
517
+ ...checkBanco(root, config),
413
518
  ...checkWorks(root),
414
519
  ...checkStaleBlocks(root, options.now ?? new Date(), options.staleBlockDays ?? config.bloqueios.dias_para_aviso),
415
520
  ...checkEventLogs(root),
package/dist/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { EOL } from 'node:os';
2
+ import { EOL, homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
  import * as p from '@clack/prompts';
5
5
  import { allCommands, loadCatalog, recommendedCommands } from './catalog.js';
@@ -11,6 +11,17 @@ import { describePlan, executePlan, packageInfo, planInstall, resolveInstallOpti
11
11
  import { detectProjectKind } from './install/selection.js';
12
12
  export { detectProjectKind, neededSkillsFor, resolveSelection } from './install/selection.js';
13
13
  const PLUGIN_HOOKS_NOTE = 'O plugin fontdata@ai-harness está habilitado: os hooks já rodam por ele e não foram instalados no projeto, para não rodarem duas vezes.';
14
+ const PLUGIN_MCP_NOTE = 'O plugin fontdata@ai-harness está habilitado: o servidor MCP de banco já roda por ele e não foi registrado no .mcp.json, para não duplicar.';
15
+ /** Registra o MCP de banco no .mcp.json quando não foi recusado (flag, wizard ou config) e o plugin não o fornece. */
16
+ export function decidirRegistroMcp(options, root, home = homedir()) {
17
+ return options.mcpBanco && readConfig(root).config.banco_dados.registrar_mcp && !isHarnessPluginEnabled(root, home);
18
+ }
19
+ function applyMcpDecision(plan, options, root) {
20
+ plan.registerMcp = decidirRegistroMcp(options, root);
21
+ plan.mcpRecusado = !options.mcpBanco;
22
+ if (!plan.registerMcp && options.mcpBanco)
23
+ plan.mcpSkipReason = isHarnessPluginEnabled(root) ? 'plugin' : 'config';
24
+ }
14
25
  /** Encerra o fluxo com mensagem amigável quando o usuário cancela (Ctrl+C). */
15
26
  function bail(value) {
16
27
  if (p.isCancel(value)) {
@@ -97,6 +108,7 @@ function runNonInteractive(args) {
97
108
  if (skippedForPlugin)
98
109
  options.hooks = false;
99
110
  const plan = planInstall(catalog, repoPath, options);
111
+ applyMcpDecision(plan, options, repoPath);
100
112
  const summary = [describePlan(plan), ...(skippedForPlugin ? [PLUGIN_HOOKS_NOTE] : [])].join(EOL + EOL);
101
113
  if (!args.yes) {
102
114
  console.log(`Simulação — nada foi gravado em ${repoPath}.\n\n${summary}\n\nRode de novo com --yes para aplicar.`);
@@ -184,6 +196,16 @@ async function runWizard(args) {
184
196
  initialValue: true,
185
197
  }));
186
198
  }
199
+ let mcpBanco = true;
200
+ if (isHarnessPluginEnabled(repoPath)) {
201
+ p.note(PLUGIN_MCP_NOTE, 'Banco');
202
+ }
203
+ else {
204
+ mcpBanco = bail(await p.confirm({
205
+ message: 'Registrar o servidor MCP de banco (somente leitura) no .mcp.json?',
206
+ initialValue: true,
207
+ }));
208
+ }
187
209
  const agents = bail(await p.confirm({
188
210
  message: 'Instalar os agents somente leitura do harness (revisor-testes, qa, investigador, auditor-plano)?',
189
211
  initialValue: true,
@@ -201,6 +223,7 @@ async function runWizard(args) {
201
223
  hooks,
202
224
  agents,
203
225
  comments,
226
+ mcpBanco,
204
227
  };
205
228
  let plan = planInstall(catalog, repoPath, options);
206
229
  // 6. Confirmação final
@@ -221,6 +244,7 @@ async function runWizard(args) {
221
244
  if (conflict === 'skip')
222
245
  plan = planInstall(catalog, repoPath, { ...options, conflict });
223
246
  }
247
+ applyMcpDecision(plan, options, repoPath);
224
248
  // Execução
225
249
  const s = p.spinner();
226
250
  s.start('Instalando...');
@@ -7,7 +7,7 @@ const CLAUDE_MD = {
7
7
  };
8
8
  const CONFLICTS = { sobrescrever: 'overwrite', pular: 'skip' };
9
9
  const VALUE_FLAGS = ['--escopo', '--comandos', '--claude-md', '--conflito'];
10
- const BOOLEAN_FLAGS = ['--sem-harness', '--sem-hooks', '--sem-agents', '--sem-comentarios', '--yes', '-y', '--sim'];
10
+ const BOOLEAN_FLAGS = ['--sem-harness', '--sem-hooks', '--sem-agents', '--sem-comentarios', '--sem-banco-dados', '--yes', '-y', '--sim'];
11
11
  function pick(flag, value, options) {
12
12
  const chosen = options[value];
13
13
  if (chosen === undefined) {
@@ -28,8 +28,13 @@ function distance(a, b) {
28
28
  }
29
29
  return row[b.length] ?? 0;
30
30
  }
31
+ /** Flags que mudaram de nome: o erro aponta a nova, que a distância de edição não alcança. */
32
+ const RENAMED_FLAGS = { '--sem-banco': '--sem-banco-dados' };
31
33
  /** Flag conhecida mais parecida com a digitada, para sugerir em mensagens de erro. */
32
34
  export function suggestFlag(token, flags) {
35
+ const renamed = RENAMED_FLAGS[token];
36
+ if (renamed && flags.includes(renamed))
37
+ return renamed;
33
38
  const [best] = flags
34
39
  .map((flag) => ({ flag, d: distance(token, flag) }))
35
40
  .filter(({ d }) => d <= 2)
@@ -48,6 +53,7 @@ export function parseInitArgs(argv) {
48
53
  hooks: undefined,
49
54
  agents: undefined,
50
55
  comments: undefined,
56
+ mcpBanco: undefined,
51
57
  yes: false,
52
58
  hasSelectionFlags: false,
53
59
  };
@@ -68,6 +74,8 @@ export function parseInitArgs(argv) {
68
74
  args.agents = false;
69
75
  else if (token === '--sem-comentarios')
70
76
  args.comments = false;
77
+ else if (token === '--sem-banco-dados')
78
+ args.mcpBanco = false;
71
79
  else
72
80
  args.yes = true;
73
81
  continue;
@@ -0,0 +1,77 @@
1
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { timestamp } from './settings.js';
4
+ export const MCP_PATH = '.mcp.json';
5
+ /** Nome do servidor de banco no `.mcp.json`; as ferramentas ficam `mcp__banco_dados__*`. */
6
+ export const BANCO_MCP_NOME = 'banco_dados';
7
+ /** Nome do servidor até a 2.7.0; a entrada gerenciada com ele é trocada pela nova no update. */
8
+ export const BANCO_MCP_NOME_LEGADO = 'banco';
9
+ const PIN_PREFIX = '@fontdata/ai-harness@';
10
+ function isPlainObject(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ /** Servidor MCP de banco com a versão do pacote fixada no npx, no mesmo formato do plugin. */
14
+ export function bancoMcpServer(versao) {
15
+ return { command: 'npx', args: ['-y', `${PIN_PREFIX}${versao}`, 'banco-dados-mcp'] };
16
+ }
17
+ function pinOf(entrada) {
18
+ if (!isPlainObject(entrada) || !Array.isArray(entrada.args))
19
+ return undefined;
20
+ return entrada.args.find((arg) => typeof arg === 'string' && arg.includes(PIN_PREFIX));
21
+ }
22
+ /** Entrada gravada pelo harness: os args fixam `@fontdata/ai-harness@<versão>`. */
23
+ export function ehEntradaGerenciada(entrada) {
24
+ return pinOf(entrada) !== undefined;
25
+ }
26
+ /**
27
+ * Acrescenta os servidores ausentes em `mcpServers` do `.mcp.json`, preservando o resto. Com
28
+ * `atualizarGerenciada`, regrava também a entrada gerenciada que fixa outra versão; entrada
29
+ * personalizada nunca é tocada. Os nomes de `removerGerenciadas` saem só quando a entrada é gerenciada. Backup
30
+ * datado só quando muda; JSON inválido é recusado sem tocar.
31
+ */
32
+ export function mergeMcpServers(root, servidores, now = new Date(), { atualizarGerenciada = false, removerGerenciadas = [] } = {}) {
33
+ const file = join(root, MCP_PATH);
34
+ const original = existsSync(file) ? readFileSync(file, 'utf8') : null;
35
+ let config = {};
36
+ if (original !== null) {
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(original);
40
+ }
41
+ catch {
42
+ parsed = undefined;
43
+ }
44
+ if (!isPlainObject(parsed) || (parsed.mcpServers !== undefined && !isPlainObject(parsed.mcpServers))) {
45
+ throw new Error(`O ${MCP_PATH} está com JSON inválido; corrija-o antes de registrar o servidor MCP de banco.`);
46
+ }
47
+ config = parsed;
48
+ }
49
+ const mcpServers = { ...(config.mcpServers ?? {}) };
50
+ let changed = false;
51
+ for (const nome of removerGerenciadas) {
52
+ if (!ehEntradaGerenciada(mcpServers[nome]))
53
+ continue;
54
+ delete mcpServers[nome];
55
+ changed = true;
56
+ }
57
+ for (const [nome, servidor] of Object.entries(servidores)) {
58
+ const atual = mcpServers[nome];
59
+ if (atual === undefined) {
60
+ mcpServers[nome] = servidor;
61
+ changed = true;
62
+ }
63
+ else if (atualizarGerenciada && ehEntradaGerenciada(atual) && pinOf(atual) !== pinOf(servidor)) {
64
+ mcpServers[nome] = { ...atual, command: servidor.command, args: servidor.args };
65
+ changed = true;
66
+ }
67
+ }
68
+ if (!changed)
69
+ return { changed: false, backup: null };
70
+ let backup = null;
71
+ if (original !== null) {
72
+ backup = `${MCP_PATH}.bak-${timestamp(now)}`;
73
+ copyFileSync(file, join(root, backup));
74
+ }
75
+ writeFileSync(file, `${JSON.stringify({ ...config, mcpServers }, null, 2)}\n`);
76
+ return { changed: true, backup };
77
+ }
@@ -6,8 +6,10 @@ import { applyAtomically } from './atomic.js';
6
6
  import { buildLockItem, LOCK_VERSION, readLock, writeLock } from './lock.js';
7
7
  import { HOOKS_ITEM_ID, PROJECT_HOOKS_DIR, projectHooksConfig } from './hooks.js';
8
8
  import { neededSkillsFor, resolveSelection } from './selection.js';
9
+ import { BANCO_MCP_NOME, bancoMcpServer, MCP_PATH, mergeMcpServers } from './mcp.js';
10
+ import { gravarRegistrarMcp } from '../config/config.js';
9
11
  import { COMMENTS_RULE_PATH, commentsHooksConfig, ensureCommentsBaseline } from '../comentarios/instalar.js';
10
- import { combineHooks, HARNESS_DENY, HARNESS_PERMISSIONS, mergeSettings } from './settings.js';
12
+ import { combineHooks, HARNESS_DENY, harnessPermissions, mergeSettings } from './settings.js';
11
13
  /** Rule de convenção com o que é específico do projeto (D-38): é do projeto, então fica fora do lock. */
12
14
  export const PROJECT_RULE_PATH = '.claude/rules/harness-projeto.md';
13
15
  /** Esqueleto da rule de projeto; os comandos `-legado` renomeados pelo update entram na última seção. */
@@ -27,6 +29,7 @@ export function resolveInstallOptions(args) {
27
29
  hooks: args.hooks ?? true,
28
30
  agents: args.agents ?? true,
29
31
  comments: args.comments ?? true,
32
+ mcpBanco: args.mcpBanco ?? true,
30
33
  };
31
34
  }
32
35
  export function packageInfo() {
@@ -162,10 +165,14 @@ export function executePlan(root, plan, pkg, now = new Date()) {
162
165
  const permission = needsEventPermission(plan);
163
166
  const hooks = combineHooks(plan.registerHooks ? projectHooksConfig() : {}, plan.registerComments ? commentsHooksConfig() : {});
164
167
  if (Object.keys(hooks).length || permission) {
165
- mergeSettings(root, { hooks, allow: permission ? HARNESS_PERMISSIONS : [], deny: permission ? HARNESS_DENY : [] }, now);
168
+ mergeSettings(root, { hooks, allow: permission ? harnessPermissions({ banco: bancoAtivo(plan) }) : [], deny: permission ? HARNESS_DENY : [] }, now);
166
169
  }
167
170
  if (plan.registerComments)
168
171
  ensureCommentsBaseline(root);
172
+ if (plan.registerMcp)
173
+ mergeMcpServers(root, { [BANCO_MCP_NOME]: bancoMcpServer(pkg.versao) }, now);
174
+ if (plan.mcpRecusado)
175
+ gravarRegistrarMcp(root, false);
169
176
  const items = new Map();
170
177
  for (const item of previous?.itens ?? [])
171
178
  items.set(`${item.tipo}:${item.id}`, item);
@@ -186,6 +193,10 @@ export function executePlan(root, plan, pkg, now = new Date()) {
186
193
  writeLock(root, lock);
187
194
  return lock;
188
195
  }
196
+ /** O banco não foi recusado (`--sem-banco-dados` ou `banco_dados.registrar_mcp: false`): as permissões do MCP entram. */
197
+ function bancoAtivo(plan) {
198
+ return !plan.mcpRecusado && plan.mcpSkipReason !== 'config';
199
+ }
189
200
  /** Skills e agents do harness registram o rastro com `npx @fontdata/ai-harness evento`. */
190
201
  function needsEventPermission(plan) {
191
202
  return plan.items.some((item) => item.tipo === 'skill' || item.tipo === 'agent');
@@ -202,10 +213,22 @@ export function describePlan(plan) {
202
213
  if (plan.registerHooks)
203
214
  lines.push('Hooks do harness registrados em .claude/settings.json (sem duplicar os existentes).');
204
215
  if (needsEventPermission(plan)) {
205
- lines.push(`Permissões ${HARNESS_PERMISSIONS.join(', ')} em .claude/settings.json, para o rastro, a checagem de conflitos, ` +
216
+ lines.push(`Permissões ${harnessPermissions({ banco: bancoAtivo(plan) }).join(', ')} em .claude/settings.json, para o rastro, a checagem de conflitos, ` +
206
217
  'o /git-commit-push, os sub-agents e as consultas de leitura não pedirem aprovação (inclusive no modo auto); ' +
207
218
  'o push forçado fica bloqueado em permissions.deny.');
208
219
  }
220
+ if (plan.registerMcp) {
221
+ lines.push(`Servidor MCP de banco (somente leitura) registrado em ${MCP_PATH}, sem duplicar os servidores existentes.`);
222
+ }
223
+ else if (plan.mcpRecusado) {
224
+ lines.push(`Servidor MCP de banco não registrado (recusado); a escolha fica em banco_dados.registrar_mcp: false no .ai-harness/config.json.`);
225
+ }
226
+ else if (plan.mcpSkipReason === 'plugin') {
227
+ lines.push(`Servidor MCP de banco não registrado em ${MCP_PATH}: o plugin fontdata@ai-harness já o fornece.`);
228
+ }
229
+ else if (plan.mcpSkipReason === 'config') {
230
+ lines.push(`Servidor MCP de banco não registrado em ${MCP_PATH}: banco_dados.registrar_mcp está false no .ai-harness/config.json.`);
231
+ }
209
232
  if (plan.registerComments) {
210
233
  lines.push('Padrão de comentários: rule carregada em toda sessão, hook que bloqueia a violação e catraca do legado (gerada só na primeira instalação).');
211
234
  }
@@ -33,6 +33,14 @@ export const READ_PERMISSIONS = [
33
33
  'Bash(gh pr diff:*)',
34
34
  'Bash(npm outdated:*)',
35
35
  ];
36
+ const BANCO_TOOLS = ['listar_conexoes', 'listar_tabelas', 'descrever_tabela', 'consultar'];
37
+ /**
38
+ * Ferramentas do servidor MCP de banco, registrado pelo `.mcp.json` (`banco_dados`) ou pelo plugin
39
+ * (`banco_dados`). A `consultar` também é liberada: os dados pessoais chegam mascarados.
40
+ */
41
+ export const BANCO_PERMISSIONS = ['mcp__banco_dados__', 'mcp__plugin_fontdata_banco_dados__'].flatMap((prefixo) => BANCO_TOOLS.map((ferramenta) => `${prefixo}${ferramenta}`));
42
+ /** As mesmas regras com o nome antigo do servidor (`banco`, até a 2.7.0); o update as remove. */
43
+ export const BANCO_PERMISSIONS_LEGADAS = ['mcp__banco__', 'mcp__plugin_fontdata_banco__'].flatMap((prefixo) => BANCO_TOOLS.map((ferramenta) => `${prefixo}${ferramenta}`));
36
44
  /** Regras de `permissions.allow` que o init e o update gravam para as skills e os agents do harness. */
37
45
  export const HARNESS_PERMISSIONS = [
38
46
  EVENT_PERMISSION,
@@ -41,7 +49,12 @@ export const HARNESS_PERMISSIONS = [
41
49
  AGENT_PERMISSION,
42
50
  ...TOOL_PERMISSIONS,
43
51
  ...READ_PERMISSIONS,
52
+ ...BANCO_PERMISSIONS,
44
53
  ];
54
+ /** As regras do harness; sem o banco (recusado com `--sem-banco-dados` ou `banco_dados.registrar_mcp: false`), sem as do MCP. */
55
+ export function harnessPermissions({ banco }) {
56
+ return banco ? HARNESS_PERMISSIONS : HARNESS_PERMISSIONS.filter((rule) => !BANCO_PERMISSIONS.includes(rule));
57
+ }
45
58
  /** O push liberado não vale para reescrever o histórico remoto: `deny` vence o `allow`. */
46
59
  export const HARNESS_DENY = [
47
60
  'Bash(git push --force:*)',
@@ -54,7 +67,7 @@ export const HARNESS_DENY = [
54
67
  function isPlainObject(value) {
55
68
  return typeof value === 'object' && value !== null && !Array.isArray(value);
56
69
  }
57
- function timestamp(date) {
70
+ export function timestamp(date) {
58
71
  const pad = (n) => String(n).padStart(2, '0');
59
72
  return (`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-` +
60
73
  `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`);
@@ -86,7 +99,7 @@ export function mergeHooksIntoSettings(root, hooks, now = new Date()) {
86
99
  * Acrescenta hooks e regras de `permissions.allow` ao `.claude/settings.json` do projeto
87
100
  * sem perder o que já existe. Um hook é considerado presente quando algum grupo do mesmo
88
101
  * evento já tem o mesmo `command`, e uma regra quando já está na lista, o que torna a
89
- * operação idempotente. Tudo é gravado de uma vez, com um único backup datado do arquivo
102
+ * operação idempotente. As regras de `removeAllow` saem de `permissions.allow`. Tudo é gravado de uma vez, com um único backup datado do arquivo
90
103
  * original; JSON inválido é recusado sem tocar em nada.
91
104
  */
92
105
  export function mergeSettings(root, additions, now = new Date()) {
@@ -149,6 +162,12 @@ export function mergeSettings(root, additions, now = new Date()) {
149
162
  }
150
163
  next.permissions = { ...permissions, [key]: list };
151
164
  }
165
+ const remove = additions.removeAllow ?? [];
166
+ const currentAllow = next.permissions?.allow;
167
+ if (remove.length && currentAllow?.some((rule) => remove.includes(rule))) {
168
+ next.permissions = { ...next.permissions, allow: currentAllow.filter((rule) => !remove.includes(rule)) };
169
+ changed = true;
170
+ }
152
171
  if (!changed)
153
172
  return { changed: false, backup: null };
154
173
  let backup = null;
@@ -2,15 +2,45 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { allCommands, loadCatalog } from '../catalog.js';
4
4
  import { commentsHooksConfig, ensureCommentsBaseline } from '../comentarios/instalar.js';
5
- import { readConfig, syncGitignore, writeDefaultConfig } from '../config/config.js';
5
+ import { CONFIG_PATH, gravarRegistrarMcp, migrarSecaoBanco, readConfig, syncGitignore, temSecaoBancoLegada, writeDefaultConfig, } from '../config/config.js';
6
6
  import { applyAtomically } from '../install/atomic.js';
7
7
  import { suggestFlag } from '../install/flags.js';
8
8
  import { HOOKS_ITEM_ID, isHarnessPluginEnabled, projectHooksConfig } from '../install/hooks.js';
9
+ import { BANCO_MCP_NOME, BANCO_MCP_NOME_LEGADO, bancoMcpServer, ehEntradaGerenciada, MCP_PATH, mergeMcpServers } from '../install/mcp.js';
9
10
  import { hashContent, LOCK_PATH, LOCK_VERSION, readLock, writeLock } from '../install/lock.js';
10
11
  import { itemSources, packageInfo, PROJECT_RULE_PATH, projectRuleContent } from '../install/plan.js';
11
12
  import { neededSkillsFor } from '../install/selection.js';
12
- import { combineHooks, HARNESS_DENY, HARNESS_PERMISSIONS, hasAllowRule, mergeSettings } from '../install/settings.js';
13
+ import { BANCO_PERMISSIONS_LEGADAS, combineHooks, HARNESS_DENY, harnessPermissions, hasAllowRule, mergeSettings, SETTINGS_PATH, } from '../install/settings.js';
13
14
  import { adoptLegacyInstall } from './legado.js';
15
+ function isPlainObject(value) {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
18
+ /** Espelha o `mergeMcpServers` com `atualizarGerenciada`; JSON inválido vira `invalido`, e o apply pula o registro. */
19
+ function mcpChange(root, versao) {
20
+ const file = join(root, MCP_PATH);
21
+ if (!existsSync(file))
22
+ return 'ausente';
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
26
+ }
27
+ catch {
28
+ return 'invalido';
29
+ }
30
+ if (!isPlainObject(parsed))
31
+ return 'invalido';
32
+ const servers = parsed.mcpServers === undefined ? {} : parsed.mcpServers;
33
+ if (!isPlainObject(servers))
34
+ return 'invalido';
35
+ if (ehEntradaGerenciada(servers[BANCO_MCP_NOME_LEGADO]))
36
+ return 'renomear';
37
+ const atual = servers[BANCO_MCP_NOME];
38
+ if (atual === undefined)
39
+ return 'faltando';
40
+ const pin = bancoMcpServer(versao).args.find((arg) => arg.startsWith('@fontdata/'));
41
+ const args = isPlainObject(atual) && Array.isArray(atual.args) ? atual.args : [];
42
+ return ehEntradaGerenciada(atual) && !args.includes(pin) ? 'versao' : null;
43
+ }
14
44
  /** `<id>-legado.md`, com sufixo numérico quando o nome já está em uso. */
15
45
  function legacyPath(root, path) {
16
46
  const base = path.replace(/\.md$/, '');
@@ -20,7 +50,7 @@ function legacyPath(root, path) {
20
50
  return candidate;
21
51
  }
22
52
  }
23
- export function checkUpdate(root, catalog = loadCatalog(), legacy, home) {
53
+ export function checkUpdate(root, catalog = loadCatalog(), legacy, home, { semBanco = false } = {}) {
24
54
  const recorded = readLock(root);
25
55
  const lock = recorded ?? adoptLegacyInstall(root, legacy);
26
56
  if (!lock) {
@@ -108,10 +138,14 @@ export function checkUpdate(root, catalog = loadCatalog(), legacy, home) {
108
138
  addItem('rule', 'comentarios', true);
109
139
  addItem('hook', 'comentarios', true);
110
140
  }
141
+ const registrarMcpConfig = readConfig(root).config.banco_dados.registrar_mcp;
142
+ const permissoesBanco = !semBanco && registrarMcpConfig;
111
143
  const permissoesFaltando = [
112
- ...HARNESS_PERMISSIONS.filter((rule) => !hasAllowRule(root, rule)),
144
+ ...harnessPermissions({ banco: permissoesBanco }).filter((rule) => !hasAllowRule(root, rule)),
113
145
  ...HARNESS_DENY.filter((rule) => !hasAllowRule(root, rule, 'deny')).map((rule) => `deny ${rule}`),
114
146
  ];
147
+ const permissoesLegadas = BANCO_PERMISSIONS_LEGADAS.filter((rule) => hasAllowRule(root, rule));
148
+ const registrarMcp = !semBanco && registrarMcpConfig && !isHarnessPluginEnabled(root, home);
115
149
  return {
116
150
  changes,
117
151
  lock,
@@ -121,6 +155,12 @@ export function checkUpdate(root, catalog = loadCatalog(), legacy, home) {
121
155
  registrarComentarios: adotada,
122
156
  permissaoAusente: permissoesFaltando.length > 0,
123
157
  permissoesFaltando,
158
+ permissoesLegadas,
159
+ permissoesBanco,
160
+ registrarMcp,
161
+ mudancaMcp: registrarMcp ? mcpChange(root, packageInfo().versao) : null,
162
+ recusarMcp: semBanco && registrarMcpConfig,
163
+ renomearSecaoBanco: temSecaoBancoLegada(root),
124
164
  };
125
165
  }
126
166
  /**
@@ -159,7 +199,10 @@ export function applyUpdate(root, report, options, pkg, now = new Date()) {
159
199
  return { de: path, para };
160
200
  });
161
201
  const hooks = combineHooks(report.registrarHooks ? projectHooksConfig() : {}, report.registrarComentarios ? commentsHooksConfig() : {});
162
- mergeSettings(root, { hooks, allow: HARNESS_PERMISSIONS, deny: HARNESS_DENY }, now);
202
+ const ajustes = [];
203
+ const settings = mergeSettings(root, { hooks, allow: harnessPermissions({ banco: report.permissoesBanco }), deny: HARNESS_DENY, removeAllow: BANCO_PERMISSIONS_LEGADAS }, now);
204
+ if (settings.changed)
205
+ ajustes.push(`${SETTINGS_PATH} atualizado${settings.backup ? ` (backup em ${settings.backup})` : ''}.`);
163
206
  if (report.registrarComentarios)
164
207
  ensureCommentsBaseline(root);
165
208
  if (report.adotada) {
@@ -172,6 +215,29 @@ export function applyUpdate(root, report, options, pkg, now = new Date()) {
172
215
  writeFileSync(projectRule, projectRuleContent(renamed.map(({ para }) => para)));
173
216
  }
174
217
  }
218
+ const avisos = [];
219
+ if (report.registrarMcp && report.mudancaMcp === 'invalido')
220
+ avisos.push(MCP_INVALIDO);
221
+ else if (report.registrarMcp) {
222
+ // Uma falha no .mcp.json nunca deixa o update pela metade, sem lock.
223
+ try {
224
+ const mcp = mergeMcpServers(root, { [BANCO_MCP_NOME]: bancoMcpServer(pkg.versao) }, now, {
225
+ atualizarGerenciada: true,
226
+ removerGerenciadas: [BANCO_MCP_NOME_LEGADO],
227
+ });
228
+ if (mcp.changed)
229
+ ajustes.push(`${MCP_PATH} com mcpServers.${BANCO_MCP_NOME} na versão ${pkg.versao}${mcp.backup ? ` (backup em ${mcp.backup})` : ''}.`);
230
+ }
231
+ catch (error) {
232
+ avisos.push(error instanceof Error ? error.message : String(error));
233
+ }
234
+ }
235
+ if (report.renomearSecaoBanco && migrarSecaoBanco(root))
236
+ ajustes.push(`seção banco renomeada para banco_dados em ${CONFIG_PATH}.`);
237
+ if (report.recusarMcp) {
238
+ gravarRegistrarMcp(root, false);
239
+ ajustes.push(`banco_dados.registrar_mcp: false gravado em ${CONFIG_PATH}.`);
240
+ }
175
241
  const itemOf = new Map(report.changes.map((change) => [change.caminho, change.item]));
176
242
  const writtenHash = new Map(write.map((file) => [file.path, hashContent(file.content)]));
177
243
  const dropped = new Set([...remove, ...report.stale, ...legacy]);
@@ -205,8 +271,15 @@ export function applyUpdate(root, report, options, pkg, now = new Date()) {
205
271
  itens.push({ ...added, arquivos: added.arquivos.sort((a, b) => a.caminho.localeCompare(b.caminho)) });
206
272
  }
207
273
  writeLock(root, { lock_versao: LOCK_VERSION, pacote: pkg, gerado_em: now.toISOString(), itens });
208
- return { written: write.map((file) => file.path), removed: remove, kept, renamed };
274
+ return { written: write.map((file) => file.path), removed: remove, kept, renamed, avisos, ajustes };
209
275
  }
276
+ const MCP_INVALIDO = `O ${MCP_PATH} está com JSON inválido: o registro do MCP de banco foi pulado e o arquivo não foi tocado. Corrija-o e rode o update de novo.`;
277
+ const MCP_DESCRIPTIONS = {
278
+ ausente: 'não existe',
279
+ faltando: `não tem a entrada ${BANCO_MCP_NOME}`,
280
+ versao: `fixa outra versão do servidor ${BANCO_MCP_NOME}`,
281
+ renomear: `tem a entrada ${BANCO_MCP_NOME_LEGADO} do harness, com o nome antigo; ela é trocada pela ${BANCO_MCP_NOME}`,
282
+ };
210
283
  const DESCRIPTIONS = {
211
284
  novo: 'novo no pacote, será gravado',
212
285
  alterado: 'mudou no pacote, será atualizado',
@@ -230,20 +303,34 @@ export function describeUpdate(report) {
230
303
  `Faltam permissões do harness em .claude/settings.json (${report.permissoesFaltando.join(', ')}); o --yes as acrescenta.`,
231
304
  ]
232
305
  : [];
306
+ const legadas = report.permissoesLegadas.length
307
+ ? [`Permissões do MCP de banco com o nome antigo em .claude/settings.json (${report.permissoesLegadas.join(', ')}); o --yes as remove.`]
308
+ : [];
309
+ const mcp = [
310
+ ...(report.mudancaMcp === 'invalido'
311
+ ? [`O ${MCP_PATH} está com JSON inválido; o --yes pula o registro do MCP de banco e não toca no arquivo.`]
312
+ : report.mudancaMcp
313
+ ? [`O ${MCP_PATH} ${MCP_DESCRIPTIONS[report.mudancaMcp]}; o --yes grava mcpServers.${BANCO_MCP_NOME} na versão ${packageInfo().versao}.`]
314
+ : []),
315
+ ...(report.renomearSecaoBanco ? [`A seção banco de ${CONFIG_PATH} agora se chama banco_dados; o --yes a renomeia.`] : []),
316
+ ...(report.recusarMcp ? [`--sem-banco-dados: o --yes grava banco_dados.registrar_mcp: false em ${CONFIG_PATH} e não registra o MCP.`] : []),
317
+ ];
233
318
  if (!report.changes.length) {
234
- const status = adopted.length || permission.length ? 'Nenhum arquivo a atualizar.' : 'A instalação está em dia: nenhum arquivo a atualizar.';
235
- return [...adopted, ...permission, status].join('\n');
319
+ const status = adopted.length || permission.length || legadas.length || mcp.length ? 'Nenhum arquivo a atualizar.' : 'A instalação está em dia: nenhum arquivo a atualizar.';
320
+ return [...adopted, ...permission, ...legadas, ...mcp, status].join('\n');
236
321
  }
237
322
  return [
238
323
  ...adopted,
239
324
  ...permission,
325
+ ...legadas,
326
+ ...mcp,
240
327
  `Mudanças em relação ao pacote ${packageInfo().versao} (instalado: ${report.lock.pacote.versao}):`,
241
328
  ...report.changes.map((change) => ` ${change.tipo.padEnd(16)} ${change.caminho}${change.destino ? ` → ${change.destino}` : ''} (${DESCRIPTIONS[change.tipo]})`),
242
329
  ].join('\n');
243
330
  }
244
- const UPDATE_FLAGS = ['--check', '--yes', '-y', '--sim', '--forcar'];
331
+ const UPDATE_FLAGS = ['--check', '--yes', '-y', '--sim', '--forcar', '--sem-banco-dados'];
245
332
  export function parseUpdateArgs(argv) {
246
- const args = { path: undefined, check: false, yes: false, force: false };
333
+ const args = { path: undefined, check: false, yes: false, force: false, semBanco: false };
247
334
  for (const token of argv) {
248
335
  if (!token.startsWith('-')) {
249
336
  if (args.path !== undefined)
@@ -254,6 +341,8 @@ export function parseUpdateArgs(argv) {
254
341
  args.check = true;
255
342
  else if (token === '--forcar')
256
343
  args.force = true;
344
+ else if (token === '--sem-banco-dados')
345
+ args.semBanco = true;
257
346
  else if (token === '--yes' || token === '-y' || token === '--sim')
258
347
  args.yes = true;
259
348
  else {
@@ -263,16 +352,17 @@ export function parseUpdateArgs(argv) {
263
352
  }
264
353
  return args;
265
354
  }
266
- /** `ai-harness update [caminho] [--check] [--yes] [--forcar]`; sem `--yes` só simula. */
355
+ /** `ai-harness update [caminho] [--check] [--yes] [--forcar] [--sem-banco-dados]`; sem `--yes` só simula. */
267
356
  export function runUpdateCommand(argv) {
268
357
  const args = parseUpdateArgs(argv);
269
358
  const root = resolve(process.cwd(), args.path ?? '.');
270
359
  if (!existsSync(root))
271
360
  throw new Error(`Pasta não encontrada: ${root}`);
272
- const report = checkUpdate(root);
361
+ const report = checkUpdate(root, undefined, undefined, undefined, { semBanco: args.semBanco });
273
362
  console.log(describeUpdate(report));
274
- // Sem arquivo a atualizar, a adoção ainda grava o lock, e a permissão ausente ainda é acrescentada.
275
- if (args.check || (!report.changes.length && !report.adotada && !report.permissaoAusente))
363
+ // Sem arquivo a atualizar, a adoção ainda grava o lock, e a permissão e o MCP ainda são acrescentados.
364
+ const pendente = report.adotada || report.permissaoAusente || report.permissoesLegadas.length > 0 || report.mudancaMcp !== null || report.recusarMcp || report.renomearSecaoBanco;
365
+ if (args.check || (!report.changes.length && !pendente))
276
366
  return 0;
277
367
  if (!args.yes) {
278
368
  const conflicts = report.changes.some((change) => change.tipo === 'conflito');
@@ -282,6 +372,10 @@ export function runUpdateCommand(argv) {
282
372
  const result = applyUpdate(root, report, { force: args.force }, packageInfo());
283
373
  console.log(`\nAtualização aplicada: ${result.written.length} gravado(s), ${result.removed.length} apagado(s), ` +
284
374
  `${result.renamed.length} renomeado(s), ${result.kept.length} preservado(s).`);
375
+ for (const ajuste of result.ajustes)
376
+ console.log(`Gravado: ${ajuste}`);
377
+ for (const aviso of result.avisos)
378
+ console.log(`Aviso: ${aviso}`);
285
379
  if (result.renamed.length) {
286
380
  console.log('Comandos personalizados renomeados (leve o que for do projeto para uma rule e apague o arquivo):\n' +
287
381
  result.renamed.map(({ de, para }) => ` ${de} → ${para}`).join('\n'));