@pathbit/agentdevkit 0.0.23 → 0.0.25

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,11 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import fs from 'node:fs';
3
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
4
  import { dirname, join } from 'node:path';
4
5
  import childProcess, { spawnSync } from 'node:child_process';
5
6
  import { syncBuiltinESMExports } from 'node:module';
6
7
  import { createHash } from 'node:crypto';
7
8
  import test from 'node:test';
8
9
  import { projectRoot, requireNode } from '../../lib/runtime.mjs';
10
+ import { adapterManifest } from '../../lib/adapters.mjs';
9
11
  import { copyAgentStructure, prepareExamples as prepare, registeredRoots, resourceTargets, SCENARIOS } from '../SCRIPT.mjs';
10
12
 
11
13
  // As fixtures pequenas isolam a cópia; o caso source-only usa o renderizador real.
@@ -32,6 +34,23 @@ function fixture(t) {
32
34
  return { root, put, outputRoot: join(root, 'examples/generated') };
33
35
  }
34
36
 
37
+ function controlledSource(root) {
38
+ const source = join(root, 'controlled-source');
39
+ // Only four explicit resources; exclude local state and additional skills.
40
+ for (const path of ['.pbai4devs/skills/projeto/criar-skill/SKILL.md', '.pbai4devs/templates/gitignore/clients.json', '.pbai4devs/templates/gitignore/agents.ignore', '.pbai4devs/templates/gitignore/shared.ignore']) {
41
+ let current = projectRoot;
42
+ for (const part of path.split('/')) {
43
+ current = join(current, part);
44
+ assert.equal(lstatSync(current).isSymbolicLink(), false, `Fonte não pode ser link: ${path}`);
45
+ }
46
+ const destination = join(source, path);
47
+ mkdirSync(dirname(destination), { recursive: true });
48
+ copyFileSync(join(projectRoot, path), destination);
49
+ }
50
+ writeFileSync(join(source, '.gitignore'), 'node_modules/\n');
51
+ return source;
52
+ }
53
+
35
54
  test('copia recursos e configurações, excluindo payloads, caches e segredos', t => {
36
55
  const { root, put } = fixture(t);
37
56
  put('.pbai4devs/tools/demo/TOOL.json', '{}'); put('.pbai4devs/tools/demo/SCRIPT.mjs', 'export {};'); put('.pbai4devs/tools/demo/README.md', '# Tool');
@@ -110,6 +129,34 @@ test('troca de ancestral por symlink antes da criação não escreve no destino
110
129
  }
111
130
  });
112
131
 
132
+ test('troca de saída durante escrita é detectada, sem alegar atomicidade', t => {
133
+ const { root, outputRoot } = fixture(t);
134
+ const target = join(outputRoot, 'race-output'), outside = mkdtempSync(join(root, 'race-outside-'));
135
+ const sentinel = join(outside, 'sentinel'); writeFileSync(sentinel, 'Não alterar.\n');
136
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
137
+ const probe = join(root, 'race-link-probe');
138
+ try { symlinkSync(outside, probe, linkType); }
139
+ catch (error) { if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { t.skip('Links simbólicos indisponíveis.'); return; } throw error; }
140
+ unlinkSync(probe);
141
+ const original = fs.writeFileSync; let injected = false;
142
+ try {
143
+ fs.writeFileSync = (file, ...args) => {
144
+ if (!injected && String(file) === join(target, '.maturity-suite.json')) {
145
+ renameSync(target, `${target}-moved`); symlinkSync(outside, target, linkType); injected = true;
146
+ }
147
+ return original.call(fs, file, ...args);
148
+ };
149
+ syncBuiltinESMExports();
150
+ assert.throws(() => prepareExamples({ sourceRoot: root, outputRoot: target, scenarios: [SCENARIOS[0]], initializeGit: false }), /link|alterado/i);
151
+ } finally { fs.writeFileSync = original; syncBuiltinESMExports(); }
152
+ assert.equal(injected, true);
153
+ assert.equal(readFileSync(sentinel, 'utf8'), 'Não alterar.\n');
154
+ // O write já em curso pode ter criado o marcador no destino externo: a
155
+ // verificação detecta a troca, mas não é uma operação atômica de diretório.
156
+ assert.equal(existsSync(join(outside, '.maturity-suite.json')), true);
157
+ assert.equal(existsSync(join(outside, 'REPORT.json')), false);
158
+ });
159
+
113
160
  test('cada destino padrão é exclusivo, preserva provas anteriores e permanece ignorado', t => {
114
161
  const { root, outputRoot } = fixture(t);
115
162
  const git = (...args) => {
@@ -172,16 +219,16 @@ test('preparação interrompida não altera resultado anterior nem declara suces
172
219
  assert.doesNotMatch(readFileSync(index, 'utf8'), /status: passed|\| passed \|/);
173
220
  });
174
221
 
175
- test('usa entradas distribuídas do hub e mantém fallback legado', t => {
222
+ test('sem estado comprovado conserva entradas humanas mesmo com template', t => {
176
223
  const { root, put } = fixture(t);
177
224
  const template = '---\ndescription: Entrada genérica.\n---\n\n# Agentes do projeto\n';
178
225
  put('AGENTS.md', '# Produto do distribuidor\n\n[PRD](docs/prd.md)\n');
179
226
  put('.pbai4devs/templates/entrypoints/AGENTS.md', template);
180
227
  const destination = join(root, 'copy');
181
228
  const copied = copyAgentStructure(root, destination);
182
- assert.equal(readFileSync(join(destination, 'AGENTS.md'), 'utf8'), template);
229
+ assert.equal(readFileSync(join(destination, 'AGENTS.md'), 'utf8'), '# Produto do distribuidor\n\n[PRD](docs/prd.md)\n');
183
230
  assert.equal(readFileSync(join(destination, 'CLAUDE.md'), 'utf8'), 'fixture\n');
184
- assert.equal(copied.entrypoints['AGENTS.md'], '.pbai4devs/templates/entrypoints/AGENTS.md');
231
+ assert.equal(copied.entrypoints['AGENTS.md'], 'AGENTS.md');
185
232
  assert.equal(copied.entrypoints['CLAUDE.md'], 'CLAUDE.md');
186
233
  assert.equal(readFileSync(join(root, 'AGENTS.md'), 'utf8'), '# Produto do distribuidor\n\n[PRD](docs/prd.md)\n');
187
234
  });
@@ -315,7 +362,9 @@ test('checkout de fontes sem manifesto deriva clientes em memória', t => {
315
362
  test('prepare gera apontamentos reais mesmo sem pastas de clientes na origem', t => {
316
363
  const { root } = fixture(t);
317
364
  const sourceRoot = join(root, 'source-only');
318
- copyAgentStructure(projectRoot, sourceRoot);
365
+ copyAgentStructure(controlledSource(root), sourceRoot);
366
+ // Model a clean source checkout regardless of ignored local developer data.
367
+ for (const path of ['.pbai4devs/espelhos.conf', '.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json', '.pbai4devs/state/agentdevkit.json', '.pbai4devs/state/derived.json', '.pbai4devs/estado/agentdevkit.json', '.pbai4devs/estado/derived.json']) rmSync(join(sourceRoot, path), { force: true });
319
368
  const roots = registeredRoots(sourceRoot);
320
369
  for (const client of roots) rmSync(join(sourceRoot, client), { recursive: true, force: true });
321
370
  const [example] = prepare({ sourceRoot, scenarios: [SCENARIOS[0]], initializeGit: false });
@@ -325,6 +374,102 @@ test('prepare gera apontamentos reais mesmo sem pastas de clientes na origem', t
325
374
  for (const file of ['src/cli.mjs', 'bin/agentdevkit.mjs', 'scripts/release/pipeline.mjs', 'tests/cli.test.mjs']) assert.equal(existsSync(join(example.root, file)), false, file);
326
375
  });
327
376
 
377
+ for (const withState of [false, true]) test(`prepare preserva catálogos humanos na cópia e na origem (estado=${withState})`, t => {
378
+ const { root } = fixture(t), sourceRoot = join(root, `source-catalog-${withState}`);
379
+ copyAgentStructure(controlledSource(root), sourceRoot);
380
+ // Source checkouts do not include generated manifests; provide this fixture
381
+ // explicitly instead of depending on ignored files in the developer's tree.
382
+ writeFileSync(join(sourceRoot, '.pbai4devs/espelhos.conf'), adapterManifest(sourceRoot, { clients: ['codex'] }));
383
+ const state = join(sourceRoot, '.pbai4devs/state/agentdevkit.json');
384
+ const derived = join(sourceRoot, '.pbai4devs/state/derived.json');
385
+ rmSync(state, { force: true }); rmSync(derived, { force: true }); // Só cópias criadas nesta fixture.
386
+ if (withState) {
387
+ mkdirSync(dirname(state), { recursive: true });
388
+ writeFileSync(state, JSON.stringify({ schemaVersion: 1, version: '0.0.24', language: 'pt', clients: ['codex'], files: {}, blocks: {} }) + '\n');
389
+ }
390
+ const originals = {};
391
+ for (const name of ['catalog.json', 'skills.json', 'agents.json', 'rules.json']) {
392
+ const file = join(sourceRoot, '.pbai4devs', name), content = `{ "arquivo humano": ${JSON.stringify(name)} }\n`;
393
+ writeFileSync(file, content); originals[name] = content;
394
+ }
395
+ for (const path of ['.pbai4devs/.gitignore', '.codex/.gitignore']) {
396
+ const file = join(sourceRoot, path);
397
+ mkdirSync(dirname(file), { recursive: true });
398
+ writeFileSync(file, '# BEGIN AGENT IGNORE\n# Alteração humana dentro do bloco\n!/nao-apagar/\n# END AGENT IGNORE\n');
399
+ }
400
+ const skill = '.pbai4devs/skills/projeto/equipe-local/SKILL.md';
401
+ const skillFile = join(sourceRoot, skill);
402
+ mkdirSync(dirname(skillFile), { recursive: true });
403
+ writeFileSync(skillFile, '---\nname: equipe-local\ndescription: Conteúdo próprio.\n---\n\n# Equipe local\n');
404
+ const sourceIgnore = readFileSync(join(sourceRoot, '.gitignore'));
405
+ const sourceConfig = readFileSync(join(sourceRoot, '.pbai4devs/espelhos.conf'));
406
+ const [example] = prepare({ sourceRoot, scenarios: [SCENARIOS[0]], initializeGit: false });
407
+ assert.equal(example.copied.catalogSnapshots.length, 4);
408
+ for (const [name, content] of Object.entries(originals)) {
409
+ assert.equal(readFileSync(join(sourceRoot, '.pbai4devs', name), 'utf8'), content);
410
+ assert.equal(readFileSync(join(example.root, '.agent-tests/testar-maturidade/original-catalogs', name), 'utf8'), content);
411
+ assert.doesNotMatch(readFileSync(join(example.root, '.pbai4devs', name), 'utf8'), /arquivo humano/);
412
+ }
413
+ for (const path of ['.pbai4devs/.gitignore', '.codex/.gitignore']) {
414
+ const original = readFileSync(join(sourceRoot, path));
415
+ const saved = example.copied.overriddenSnapshots.find(item => item.source === path);
416
+ assert.ok(saved, path);
417
+ assert.deepEqual(readFileSync(join(example.root, saved.snapshot)), original);
418
+ assert.match(original.toString('utf8'), /Alteração humana/);
419
+ assert.doesNotMatch(readFileSync(join(example.root, path), 'utf8'), /Alteração humana/);
420
+ }
421
+ assert.equal(readFileSync(join(example.root, skill), 'utf8'), readFileSync(skillFile, 'utf8'));
422
+ assert.deepEqual(readFileSync(join(sourceRoot, '.gitignore')), sourceIgnore);
423
+ assert.deepEqual(readFileSync(join(sourceRoot, '.pbai4devs/espelhos.conf')), sourceConfig);
424
+ const report = JSON.parse(readFileSync(join(dirname(example.root), 'REPORT.json'), 'utf8'));
425
+ assert.deepEqual(report.copies[0].catalogSnapshots, example.copied.catalogSnapshots);
426
+ assert.match(readFileSync(join(example.root, 'README.md'), 'utf8'), /não um teste no projeto original/);
427
+ assert.equal(existsSync(state), withState);
428
+ });
429
+
430
+ test('arquiva arquivos substituídos mesmo quando o cliente não está selecionado', t => {
431
+ const { root, put } = fixture(t);
432
+ put('.pbai4devs/espelhos.conf', 'agente|.codex|plano\n');
433
+ const files = ['.vscode/settings.json', '.github/workflows/legacy.yml'];
434
+ for (const path of files) put(path, `Conteúdo de equipe: ${path}\r\n`);
435
+ const [example] = prepareExamples({ sourceRoot: root, scenarios: [SCENARIOS.find(item => item.id === 'legado')], initializeGit: false });
436
+ for (const path of files) {
437
+ const saved = example.copied.overriddenSnapshots.filter(item => item.source === path);
438
+ assert.equal(saved.length, 1, path);
439
+ assert.deepEqual(readFileSync(join(example.root, saved[0].snapshot)), readFileSync(join(root, path)));
440
+ }
441
+ });
442
+
443
+ test('arquivo de cliente não selecionado com ancestral simbólico é recusado', t => {
444
+ const { root, put } = fixture(t);
445
+ put('.pbai4devs/espelhos.conf', 'agente|.codex|plano\n');
446
+ const outside = mkdtempSync(join(root, 'outside-'));
447
+ writeFileSync(join(outside, 'settings.json'), 'Conteúdo externo.\n');
448
+ rmSync(join(root, '.vscode'), { recursive: true });
449
+ try { symlinkSync(outside, join(root, '.vscode'), 'dir'); }
450
+ catch (error) { if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { t.skip('Links simbólicos indisponíveis.'); return; } throw error; }
451
+ assert.throws(() => prepareExamples({ sourceRoot: root, scenarios: [SCENARIOS.find(item => item.id === 'legado')], initializeGit: false }), /link/i);
452
+ assert.equal(readFileSync(join(outside, 'settings.json'), 'utf8'), 'Conteúdo externo.\n');
453
+ });
454
+
455
+ test('arquivos da fonte substituídos por fixtures têm cópias byte a byte', t => {
456
+ const { root, put } = fixture(t);
457
+ const files = ['README.md', 'Makefile', '.dockerignore', '.env.example', '.pbai4devs/state/README.md', '.pbai4devs/state/configuracao/ESTADO.md', '.vscode/settings.json', '.github/workflows/legacy.yml'];
458
+ const contents = Object.fromEntries(files.map(file => [file, `Conteúdo humano sintético: ${file}\r\n`]));
459
+ for (const [file, content] of Object.entries(contents)) put(file, content);
460
+ const cases = [SCENARIOS.find(item => item.id === 'app-configurada'), SCENARIOS.find(item => item.id === 'legado')];
461
+ const examples = prepareExamples({ sourceRoot: root, scenarios: cases, initializeGit: false });
462
+ for (const example of examples) {
463
+ for (const { source, snapshot } of example.copied.overriddenSnapshots) {
464
+ assert.equal(readFileSync(join(example.root, snapshot), 'utf8'), contents[source], source);
465
+ assert.equal(readFileSync(join(root, source), 'utf8'), contents[source], source);
466
+ }
467
+ for (const path of ['README.md', 'Makefile', '.pbai4devs/state/README.md', '.pbai4devs/state/configuracao/ESTADO.md']) assert.ok(example.copied.overriddenSnapshots.some(item => item.source === path), path);
468
+ const expected = example.id === 'legado' ? ['.vscode/settings.json', '.github/workflows/legacy.yml'] : ['.dockerignore', '.env.example'];
469
+ for (const path of expected) assert.ok(example.copied.overriddenSnapshots.some(item => item.source === path), path);
470
+ }
471
+ });
472
+
328
473
  test('saída não pode sobrepor recursos de origem nem escapar das áreas de teste', t => {
329
474
  const { root } = fixture(t);
330
475
  const original = readFileSync(join(root, '.pbai4devs/espelhos.conf'), 'utf8');
@@ -8,6 +8,29 @@ description: Integra branches com trabalho concluído e verificado.
8
8
  Confira origem, destino, histórico, identidades, mensagens, alterações locais e
9
9
  verificações; integre somente tarefas completas conforme AGENTS.md. Se os
10
10
  commits a integrar identificarem agentes como autores ou coautores, informe e
11
- não os integre. Se gerar commit de merge, confira as identidades efetivas e siga
11
+ não os integre.
12
+
13
+ Quando houver implementação ou gate exigido, na origem execute isoladamente:
14
+
15
+ ```sh
16
+ node .pbai4devs/scripts/revisar-entrega/SCRIPT.mjs check-merge \
17
+ --destino refs/heads/<branch> <revisao.json> [outros...]
18
+ ```
19
+
20
+ O check exige fast-forward, cobertura de todo o diff e snapshots correspondentes
21
+ ao commit de origem; qualquer falha bloqueia a integração. Registre os hashes
22
+ de origem e destino retornados e confirme ambos imediatamente antes do merge,
23
+ com comandos que interrompam a execução em qualquer falha; não mascare o status.
24
+ Confirme também que `git symbolic-ref HEAD` é a ref de destino e que o HEAD
25
+ corresponde ao hash de destino validado. Se alguma conferência falhar,
26
+ interrompa; se os hashes mudarem, refaça a revisão e o check. Integre com
27
+ `git merge --ff-only <hash-da-origem-validada>`. Essas verificações não são
28
+ atômicas. O check recusa links, submódulos, arquivos executáveis e saídas Git
29
+ maiores que 64 MiB; informe bloqueios sem contorná-los. Preserve trabalho fora
30
+ do escopo.
31
+ Se o resultado integrado divergir do conteúdo revisado, faça nova revisão e
32
+ verificações antes de concluir. Sem implementação, siga os critérios aplicáveis.
33
+
34
+ Se gerar commit de merge, confira as identidades efetivas e siga
12
35
  `.pbai4devs/rules/git/RULE.md` para identidade, mensagem e assinatura. Preserve
13
36
  alterações, resolva conflitos no escopo autorizado e respeite proteções da branch.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.25 — 2026-11-14
8
+
9
+ - Verifica a cobertura e o conteúdo revisado antes de um merge fast-forward;
10
+ bloqueia situações que o gate não consegue comprovar.
11
+ - Esclarece a validação na origem e a interrupção do merge quando o gate falha.
12
+ - Amplia as provas de preservação em projetos existentes e explica os limites
13
+ do campo `preserved`. Os projetos de exemplo gerados continuam locais.
14
+
15
+ ## 0.0.24 — 2026-11-14
16
+
17
+ - Preserva catálogos, configuração e blocos existentes sem comprovação de
18
+ propriedade, inclusive ao usar `--force` ou sincronização direta.
19
+ - Mantém o estado coerente quando a sincronização atualiza saídas gerenciadas.
20
+ - Acrescenta testes de colisão, arquivos idênticos e preservação do índice Git.
21
+
7
22
  ## 0.0.23 — 2026-11-13
8
23
 
9
24
  - Corrige a resolução de destinos inexistentes diretamente sob a raiz.
@@ -183,9 +183,11 @@ Arquivos mistos não passam a pertencer ao kit. Sua visibilidade para o Git
183
183
  também depende da regra aplicada à pasta do cliente.
184
184
 
185
185
  Links simbólicos preexistentes são preservados sem atravessá-los. O campo
186
- `preserved` informa rotas que continuam sob controle do projeto. Um formato
187
- não suportado ou um bloco alterado é informado; `--force` não permite apagar
188
- instruções humanas para reconstruir um apontamento.
186
+ `preserved` lista caminhos próprios encontrados no planejamento ou em colisões,
187
+ sem inventariar rotas não examinadas. A ausência de um caminho não prova que ele
188
+ foi verificado ou preservado. Um formato não suportado ou um bloco alterado é
189
+ informado; `--force` não permite apagar instruções humanas para reconstruir um
190
+ apontamento.
189
191
 
190
192
  Essa preservação não autoriza trocar arquivos gerenciados por links. Links
191
193
  em caminhos já pertencentes ao kit, no hub, no estado ou em ferramentas
@@ -158,7 +158,10 @@ Não apague pastas inteiras para contornar um conflito.
158
158
 
159
159
  Recursos nativos humanos são preservados, inclusive com `--force` e nomes
160
160
  iguais aos do kit. Links humanos são tratados como caminhos opacos; o gerador
161
- não os atravessa. A CLI informa essas rotas em `preserved` no JSON.
161
+ não os atravessa. No JSON, `preserved` lista caminhos humanos encontrados no
162
+ planejamento ou em colisões; não inventaria rotas não examinadas, como um
163
+ fallback não utilizado. A ausência de um caminho não prova que ele foi
164
+ verificado ou preservado.
162
165
 
163
166
  Não edite ou versione `catalog.json`, `skills.json`, `rules.json` ou
164
167
  `agents.json`: são derivados locais. Configurações nativas e documentos
@@ -56,9 +56,10 @@ Compare cada `conflicts` com o que deseja manter. Edite a origem em
56
56
  recurso. Use `sync` para atualizar suas derivações. `update` reaplica os
57
57
  recursos do pacote invocado e pode conflitar com essa personalização.
58
58
 
59
- Quando a substituição for intencional, repita com `--force`. Arquivos
60
- existentes afetados recebem backup antes da escrita, inclusive em operações
61
- normais. O campo `backup` informa uma pasta relativa ao projeto:
59
+ `--force` não substitui arquivos próprios nem blocos alterados. Resolva o
60
+ conflito preservando o conteúdo que deseja manter e confira novamente o plano.
61
+ Arquivos gerenciados afetados recebem backup antes da escrita, inclusive em
62
+ operações normais. O campo `backup` informa uma pasta relativa ao projeto:
62
63
 
63
64
  ```text
64
65
  .agent-tests/agentdevkit/backups/<identificador>/
@@ -105,9 +106,10 @@ ou caminhos já gerenciados em links.
105
106
  Destinos dentro de `.git`, `node_modules` ou `.pbai4devs` são recusados,
106
107
  inclusive com variações de maiúsculas/minúsculas.
107
108
 
108
- Na CLI e no script `sincronizar-agentes`, `--force` repara conflitos gerenciados
109
- com backup. Recursos nativos humanos e links preservados continuam intactos.
110
- Para manter a instalação e sua seleção de clientes, prefira a CLI.
109
+ No script `sincronizar-agentes`, `--force` repara conflitos gerenciados com
110
+ backup. A CLI bloqueia conflitos de propriedade mesmo com `--force`. Recursos
111
+ nativos humanos e links preservados continuam intactos. Para manter a instalação
112
+ e sua seleção de clientes, prefira a CLI.
111
113
 
112
114
  ## Download, plataforma ou ferramenta falhando
113
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathbit/agentdevkit",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Shared skills, specifications and workflows for AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/i18n.mjs CHANGED
@@ -12,7 +12,7 @@ const messages = {
12
12
  preserved: 'Caminhos existentes preservados', preservedDetails: 'Use --json para consultar os caminhos e motivos.',
13
13
  help: 'AgentDevKit: init [diretório], update, doctor, sync [--check], lang [pt|en|es], agents [lista], next, tools <ação>.\nOpções: --target <diretório>, --lang <idioma>, --agents <lista>, --yes, --dry-run, --force, --json.\nAliases: --clients e clients.',
14
14
  clientsCurrent: 'Agentes selecionados', clientsAvailable: 'Agentes disponíveis', clientsSelect: 'Agentes (IDs separados por vírgula ou all) [all]: ', agents: 'Seleção de agentes atualizada.',
15
- next: 'Próximo passo', init: 'Estrutura instalada.', update: 'Estrutura atualizada.', sync: 'Apontamentos sincronizados.', lang: 'Idioma configurado.', unchanged: 'Estrutura já atualizada.', plan: 'Plano calculado; nenhum arquivo alterado.', conflict: 'Há arquivos próprios ou alterados em conflito. Revise o plano; --force salva backup antes de substituir.',
15
+ next: 'Próximo passo', init: 'Estrutura instalada.', update: 'Estrutura atualizada.', sync: 'Apontamentos sincronizados.', lang: 'Idioma configurado.', unchanged: 'Estrutura já atualizada.', plan: 'Plano calculado; nenhum arquivo alterado.', conflict: 'Há arquivos próprios ou alterados em conflito. Revise o plano e resolva os conflitos; --force não substitui conteúdo próprio.',
16
16
  healthy: 'Estrutura verificada.', unhealthy: 'A estrutura precisa de ajustes.', missing: 'AgentDevKit ainda não foi inicializado neste diretório.', error: 'Não foi possível concluir a operação.', invalid: 'Argumentos inválidos.', language: 'Idioma atual', version: 'Versão', select: 'Idioma / Language / Idioma [pt/en/es] (pt): ',
17
17
  project: 'Projeto', readme: 'AgentDevKit instalado. Leia AGENTS.md e siga as etapas de produto, design e implementação.', state: 'Estado local do AgentDevKit e configuração compartilhada do projeto.',
18
18
  agentLanguage: 'Responda em português, salvo quando o usuário solicitar outro idioma.', inspect: 'Verifique a instalação', workflow: 'Consulte a próxima etapa', install: 'Inicialize este projeto', repair: 'Confira a sincronização', review: 'Revise os arquivos em conflito', tools: 'Resultado da ferramenta',
@@ -23,7 +23,7 @@ const messages = {
23
23
  preserved: 'Existing paths preserved', preservedDetails: 'Use --json to inspect paths and reasons.',
24
24
  help: 'AgentDevKit: init [directory], update, doctor, sync [--check], lang [pt|en|es], agents [list], next, tools <action>.\nOptions: --target <directory>, --lang <language>, --agents <list>, --yes, --dry-run, --force, --json.\nAliases: --clients and clients.',
25
25
  clientsCurrent: 'Selected agents', clientsAvailable: 'Available agents', clientsSelect: 'Agents (comma-separated IDs or all) [all]: ', agents: 'Agent selection updated.',
26
- next: 'Next step', init: 'Structure installed.', update: 'Structure updated.', sync: 'Pointers synchronized.', lang: 'Language configured.', unchanged: 'Structure is already current.', plan: 'Plan calculated; no files changed.', conflict: 'User files or edits conflict with this plan. Review it; --force backs them up before replacement.',
26
+ next: 'Next step', init: 'Structure installed.', update: 'Structure updated.', sync: 'Pointers synchronized.', lang: 'Language configured.', unchanged: 'Structure is already current.', plan: 'Plan calculated; no files changed.', conflict: 'User files or edits conflict with this plan. Review and resolve the conflicts; --force does not replace user content.',
27
27
  healthy: 'Structure verified.', unhealthy: 'The structure needs attention.', missing: 'AgentDevKit has not been initialized in this directory.', error: 'The operation could not be completed.', invalid: 'Invalid arguments.', language: 'Current language', version: 'Version', select: 'Idioma / Language / Idioma [pt/en/es] (pt): ',
28
28
  project: 'Project', readme: 'AgentDevKit installed. Read AGENTS.md and follow the product, design and implementation stages.', state: 'AgentDevKit state and shared project configuration.',
29
29
  agentLanguage: 'Respond in English unless the user requests another language.', inspect: 'Check the installation', workflow: 'Review the next stage', install: 'Initialize this project', repair: 'Check synchronization', review: 'Review conflicting files', tools: 'Tool result',
@@ -34,7 +34,7 @@ const messages = {
34
34
  preserved: 'Rutas existentes preservadas', preservedDetails: 'Use --json para consultar las rutas y los motivos.',
35
35
  help: 'AgentDevKit: init [directorio], update, doctor, sync [--check], lang [pt|en|es], agents [lista], next, tools <acción>.\nOpciones: --target <directorio>, --lang <idioma>, --agents <lista>, --yes, --dry-run, --force, --json.\nAlias: --clients y clients.',
36
36
  clientsCurrent: 'Agentes seleccionados', clientsAvailable: 'Agentes disponibles', clientsSelect: 'Agentes (IDs separados por coma o all) [all]: ', agents: 'Selección de agentes actualizada.',
37
- next: 'Próximo paso', init: 'Estructura instalada.', update: 'Estructura actualizada.', sync: 'Referencias sincronizadas.', lang: 'Idioma configurado.', unchanged: 'La estructura ya está actualizada.', plan: 'Plan calculado; ningún archivo modificado.', conflict: 'Hay archivos propios o cambios en conflicto. Revise el plan; --force guarda una copia antes de reemplazarlos.',
37
+ next: 'Próximo paso', init: 'Estructura instalada.', update: 'Estructura actualizada.', sync: 'Referencias sincronizadas.', lang: 'Idioma configurado.', unchanged: 'La estructura ya está actualizada.', plan: 'Plan calculado; ningún archivo modificado.', conflict: 'Hay archivos propios o cambios en conflicto. Revise y resuelva los conflictos; --force no reemplaza contenido propio.',
38
38
  healthy: 'Estructura verificada.', unhealthy: 'La estructura necesita ajustes.', missing: 'AgentDevKit no se ha inicializado en este directorio.', error: 'No se pudo completar la operación.', invalid: 'Argumentos inválidos.', language: 'Idioma actual', version: 'Versión', select: 'Idioma / Language / Idioma [pt/en/es] (pt): ',
39
39
  project: 'Proyecto', readme: 'AgentDevKit instalado. Lea AGENTS.md y siga las etapas de producto, diseño e implementación.', state: 'Estado de AgentDevKit y configuración compartida del proyecto.',
40
40
  agentLanguage: 'Responde en español salvo que el usuario solicite otro idioma.', inspect: 'Compruebe la instalación', workflow: 'Consulte la siguiente etapa', install: 'Inicialice este proyecto', repair: 'Compruebe la sincronización', review: 'Revise los archivos en conflicto', tools: 'Resultado de la herramienta',
package/src/install.mjs CHANGED
@@ -457,7 +457,14 @@ function replaceJsonMember(bytes, member, value) {
457
457
  fail('E_STATE', 'Invalid JSON member separator.');
458
458
  }
459
459
  const matches = properties.filter(item => item.key === member);
460
- if (matches.length !== 1) fail('E_STATE', 'Ambiguous derived state member.');
460
+ if (matches.length > 1) fail('E_STATE', 'Ambiguous state member.');
461
+ if (!matches.length) {
462
+ if (value === undefined) return bytes;
463
+ const insertion = `${properties.length ? ',' : ''}\n ${JSON.stringify(member)}: ${JSON.stringify(value, null, 2)}\n`;
464
+ const next = text.slice(0, i) + insertion + text.slice(i);
465
+ try { JSON.parse(next); } catch { fail('E_STATE', 'Unable to preserve state safely.'); }
466
+ return Buffer.from(next);
467
+ }
461
468
  const current = matches[0], index = properties.indexOf(current);
462
469
  let next;
463
470
  if (value !== undefined) next = text.slice(0, current.valueStart) + JSON.stringify(value, null, 2) + text.slice(current.end);
@@ -468,29 +475,44 @@ function replaceJsonMember(bytes, member, value) {
468
475
  return Buffer.from(next);
469
476
  }
470
477
 
478
+ function reconcileStateBytes(original, previous, next) {
479
+ if (!original) return Buffer.from(`${JSON.stringify(next, null, 2)}\n`);
480
+ const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
481
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
482
+ : Array.isArray(value) ? value.map(canonical) : value;
483
+ let result = original;
484
+ for (const key of ['schemaVersion', 'version', 'language', 'clients', 'files', 'blocks']) {
485
+ if (JSON.stringify(canonical(previous?.[key])) !== JSON.stringify(canonical(next[key]))) result = replaceJsonMember(result, key, next[key]);
486
+ }
487
+ return result;
488
+ }
489
+
471
490
  function readDerivedState(target) {
472
- let entries = {}, outputs = {}, blocks = {}, files = {}, removedBlocks = [], removedFiles = {};
491
+ let entries = {}, outputs = {}, blocks = {}, files = {}, catalogs = {}, config = null, removedBlocks = [], removedFiles = {};
473
492
  const derived = read(target, actualStatePath(target).replace(/agentdevkit\.json$/, 'derived.json'));
474
493
  if (derived) {
475
494
  try {
476
495
  const value = JSON.parse(derived);
477
496
  if (value.schemaVersion !== 1 || !value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)
478
- || ['outputs', 'blocks', 'files', 'removedFiles'].some(key => value[key] !== undefined && (!value[key] || typeof value[key] !== 'object' || Array.isArray(value[key])))) fail('E_STATE', 'Invalid derived entrypoint state.');
479
- entries = value.entries; outputs = value.outputs ?? {}; blocks = value.blocks ?? {}; files = value.files ?? {}; removedFiles = value.removedFiles ?? {};
497
+ || ['outputs', 'blocks', 'files', 'catalogs', 'removedFiles'].some(key => value[key] !== undefined && (!value[key] || typeof value[key] !== 'object' || Array.isArray(value[key])))) fail('E_STATE', 'Invalid derived entrypoint state.');
498
+ entries = value.entries; outputs = value.outputs ?? {}; blocks = value.blocks ?? {}; files = value.files ?? {}; catalogs = value.catalogs ?? {}; removedFiles = value.removedFiles ?? {};
499
+ if (value.config !== undefined && (typeof value.config !== 'string' || !/^[a-f0-9]{64}$/.test(value.config))) fail('E_STATE', 'Invalid derived configuration state.');
500
+ config = value.config ?? null;
480
501
  removedBlocks = value.removedBlocks ?? [];
481
502
  if (!Array.isArray(removedBlocks) || removedBlocks.some(path => typeof path !== 'string' || !nativeManagedText(path))) fail('E_STATE', 'Invalid removed block state.');
482
503
  for (const [path, digest] of Object.entries(entries)) if (!canonicalEntrypoint(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived entrypoint state.');
483
504
  for (const [path, digest] of Object.entries(outputs)) if (!allowedManaged(path) || !/^\.[^/]+\/(?:skills|agents|rules)\.json$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived output state.');
484
- for (const [path, digest] of Object.entries(blocks)) if (!(nativeManagedText(path) || adapterRoots.some(root => path === `${root}/.gitignore`)) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived block state.');
505
+ for (const [path, digest] of Object.entries(blocks)) if (!(nativeManagedText(path) || path === '.pbai4devs/.gitignore' || adapterRoots.some(root => path === `${root}/.gitignore`)) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived block state.');
485
506
  for (const [path, digest] of Object.entries(files)) if (!allowedManaged(path) || !/\.(?:md|mdc|toml)$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived file state.');
507
+ for (const [path, digest] of Object.entries(catalogs)) if (!/^\.pbai4devs\/(?:catalog|skills|agents|rules)\.json$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived catalogue state.');
486
508
  for (const [path, digest] of Object.entries(removedFiles)) if (canonicalEntrypoint(path) || !(nativeMarkdown(path) || nativeToml(path)) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid removed file state.');
487
509
  const installed = loadState(target);
488
510
  if (Object.entries(removedFiles).some(([path, digest]) => installed?.files?.[path] && installed.files[path] !== digest)) fail('E_STATE', 'Removed file state differs from installation.');
489
511
  }
490
512
  catch { fail('E_STATE', 'Invalid derived entrypoint state.'); }
491
- for (const path of [...Object.keys(entries), ...Object.keys(outputs), ...Object.keys(blocks), ...Object.keys(files), ...Object.keys(removedFiles), ...removedBlocks]) safePath(target, path);
513
+ for (const path of [...Object.keys(entries), ...Object.keys(outputs), ...Object.keys(blocks), ...Object.keys(files), ...Object.keys(catalogs), ...Object.keys(removedFiles), ...removedBlocks]) safePath(target, path);
492
514
  }
493
- return { entries, outputs, blocks, files, removedBlocks, removedFiles };
515
+ return { entries, outputs, blocks, files, catalogs, config, removedBlocks, removedFiles };
494
516
  }
495
517
 
496
518
  export function planInstallation({ target, language = 'pt', mode = 'init', force = false, clients }) {
@@ -511,11 +533,12 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
511
533
  if (original === null ? content !== null : !content?.equals(original)) fail('E_CHANGED', `File changed while planning: ${path}`);
512
534
  } else nativeInputs.set(path, content);
513
535
  };
514
- const { entries: derivedEntries, outputs: derivedOutputs, blocks: derivedBlocks, files: derivedFiles, removedBlocks, removedFiles } = readDerivedState(target);
536
+ const { entries: derivedEntries, outputs: derivedOutputs, blocks: derivedBlocks, files: derivedFiles, catalogs: derivedCatalogs, config: derivedConfig, removedBlocks, removedFiles } = readDerivedState(target);
537
+ const derivedConfigFiles = derivedConfig ? { '.pbai4devs/espelhos.conf': derivedConfig } : {};
515
538
  if (previous) previous = { ...previous, files: Object.fromEntries(Object.entries(previous.files).filter(([path, digest]) => removedFiles[path] !== digest)) };
516
539
  const previousBlocks = Object.fromEntries(Object.entries(previous?.blocks || {}).filter(([path]) => !removedBlocks.includes(path)));
517
540
  const knownBlocks = { ...previousBlocks, ...derivedBlocks };
518
- const ownedPaths = [...Object.keys(previous?.files || {}), ...Object.keys(previousBlocks), ...Object.keys(derivedEntries), ...Object.keys(derivedOutputs), ...Object.keys(derivedBlocks), ...Object.keys(derivedFiles)];
541
+ const ownedPaths = [...Object.keys(previous?.files || {}), ...Object.keys(previousBlocks), ...Object.keys(derivedEntries), ...Object.keys(derivedOutputs), ...Object.keys(derivedBlocks), ...Object.keys(derivedFiles), ...Object.keys(derivedCatalogs), ...Object.keys(derivedConfigFiles)];
519
542
  const entryPaths = new Map(entryNames.map(name => [name, resolveEntrypoint(target, name)]));
520
543
  const blockHash = path => knownBlocks[path]
521
544
  ?? (canonicalEntrypoint(path) ? Object.entries(knownBlocks).find(([candidate]) => canonicalEntrypoint(candidate) === canonicalEntrypoint(path))?.[1] : undefined);
@@ -597,7 +620,17 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
597
620
  const ownIgnore = read(target, '.pbai4devs/.gitignore');
598
621
  if (ownIgnore) write(stage, '.pbai4devs/.gitignore', ownIgnore);
599
622
  writeAdapterManifest(stage, { clients: selected });
600
- const synchronized = synchronize({ root: stage, force: true, localIgnores: false, generateEntrypoints: false, recordDerivedState: false, outputBlocked, output() {} });
623
+ // Supply the already-validated ownership records to the private stage so
624
+ // the synchronizer can distinguish an owned ignore block from a user's.
625
+ // These temporary records never replace or bypass checks on the target.
626
+ const stagingState = '.pbai4devs/state/agentdevkit.json', stagingDerived = '.pbai4devs/state/derived.json';
627
+ if (previous) write(stage, stagingState, JSON.stringify(previous));
628
+ if (Object.keys(derivedEntries).length || Object.keys(derivedOutputs).length || Object.keys(derivedBlocks).length || Object.keys(derivedFiles).length || Object.keys(derivedCatalogs).length || derivedConfig || removedBlocks.length || Object.keys(removedFiles).length) {
629
+ write(stage, stagingDerived, JSON.stringify({ schemaVersion: 1, entries: derivedEntries, outputs: derivedOutputs, blocks: derivedBlocks, files: derivedFiles, catalogs: derivedCatalogs, ...(derivedConfig ? { config: derivedConfig } : {}), removedBlocks, removedFiles }));
630
+ }
631
+ let synchronized;
632
+ try { synchronized = synchronize({ root: stage, force: true, localIgnores: false, generateEntrypoints: false, recordDerivedState: false, outputBlocked, output() {} }); }
633
+ finally { rmSync(join(stage, stagingState), { force: true }); rmSync(join(stage, stagingDerived), { force: true }); }
601
634
  for (const canonical of active.entrypoints) {
602
635
  const name = entryPaths.get(canonical);
603
636
  if (preserveNative(name)) continue;
@@ -673,14 +706,14 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
673
706
  // A standalone sync records its proven outputs separately. During a later
674
707
  // check/sync, compare that overlay before declaring the installation state
675
708
  // stale; update still consolidates it into the main state transactionally.
676
- let stateContent = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
709
+ let stateContent = reconcileStateBytes(installedState ? read(target, actualStatePath(target)) : null, installedState, manifest);
677
710
  if (mode === 'sync' && installedState && !changingClients) {
678
711
  const files = { ...installedState.files }, blocks = { ...installedState.blocks };
679
712
  for (const [path, digest] of Object.entries(removedFiles)) {
680
713
  if (files[path] === digest && !stat(safePath(target, path))) delete files[path];
681
714
  }
682
715
  for (const path of removedBlocks) delete blocks[path];
683
- for (const [path, digest] of Object.entries({ ...derivedFiles, ...derivedOutputs, ...derivedEntries })) { files[path] = digest; delete blocks[path]; }
716
+ for (const [path, digest] of Object.entries({ ...derivedFiles, ...derivedOutputs, ...derivedEntries, ...derivedCatalogs, ...derivedConfigFiles })) { files[path] = digest; delete blocks[path]; }
684
717
  for (const [path, digest] of Object.entries(derivedBlocks)) { blocks[path] = digest; delete files[path]; }
685
718
  const same = (a, b) => {
686
719
  const keys = Object.keys(a).sort();
@@ -692,23 +725,70 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
692
725
  desired.set(statePath, stateContent);
693
726
  const derivedPath = actualStatePath(target).replace(/agentdevkit\.json$/, 'derived.json');
694
727
  const derivedBytes = !migration ? read(target, derivedPath) : null;
695
- if (derivedBytes && Object.keys(removedFiles).length) {
728
+ let nextDerivedBytes = derivedBytes;
729
+ if (nextDerivedBytes && Object.keys(removedFiles).length) {
696
730
  const finalState = JSON.parse(stateContent);
697
731
  const remaining = Object.fromEntries(Object.entries(removedFiles).filter(([path, digest]) =>
698
732
  finalState.files[path] === digest && !stat(safePath(target, path))));
699
733
  if (Object.keys(remaining).length !== Object.keys(removedFiles).length) {
700
734
  observeNative(derivedPath, derivedBytes);
701
- desired.set(derivedPath, replaceJsonMember(derivedBytes, 'removedFiles', Object.keys(remaining).length ? remaining : undefined));
735
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'removedFiles', Object.keys(remaining).length ? remaining : undefined);
702
736
  }
703
737
  }
738
+ if (nextDerivedBytes && Object.keys(derivedCatalogs).length) {
739
+ const finalState = JSON.parse(stateContent);
740
+ const remaining = Object.fromEntries(Object.entries(derivedCatalogs).filter(([path]) => {
741
+ const content = desired.get(path);
742
+ return !content || finalState.files[path] !== hash(content);
743
+ }));
744
+ if (Object.keys(remaining).length !== Object.keys(derivedCatalogs).length) {
745
+ observeNative(derivedPath, derivedBytes);
746
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'catalogs', Object.keys(remaining).length ? remaining : undefined);
747
+ }
748
+ }
749
+ if (nextDerivedBytes && Object.keys(derivedBlocks).length) {
750
+ const finalState = JSON.parse(stateContent);
751
+ const remaining = Object.fromEntries(Object.entries(derivedBlocks).filter(([path, digest]) => {
752
+ const content = desired.get(path);
753
+ // A deselected generated block is removed later in this transaction.
754
+ // Drop its overlay only if its current bytes are still proven; edited
755
+ // blocks remain conflicts and no metadata is committed on failure.
756
+ if (!content && !canonicalEntrypoint(path) && !finalState.blocks[path] && !finalState.files[path]) {
757
+ const current = read(target, path);
758
+ return Boolean(current && blockDigest(path, current) !== digest);
759
+ }
760
+ return !content || finalState.blocks[path] !== blockDigest(path, content);
761
+ }));
762
+ if (Object.keys(remaining).length !== Object.keys(derivedBlocks).length) {
763
+ observeNative(derivedPath, derivedBytes);
764
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'blocks', Object.keys(remaining).length ? remaining : undefined);
765
+ }
766
+ }
767
+ if (nextDerivedBytes && derivedConfig) {
768
+ const finalState = JSON.parse(stateContent), path = '.pbai4devs/espelhos.conf', content = desired.get(path);
769
+ if (content && finalState.files[path] === hash(content)) {
770
+ observeNative(derivedPath, derivedBytes);
771
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'config', undefined);
772
+ }
773
+ }
774
+ if (nextDerivedBytes && !nextDerivedBytes.equals(derivedBytes)) desired.set(derivedPath, nextDerivedBytes);
704
775
  const changes = [], conflicts = [];
705
776
  const aliasedEntrypoint = path => canonicalEntrypoint(path) && entryPaths.get(canonicalEntrypoint(path)) !== path;
777
+ const generatedInternal = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json', '.pbai4devs/espelhos.conf']);
706
778
  for (const [path, content] of desired) {
707
779
  const current = read(target, path);
708
780
  if (nativeInputs.has(path) && (nativeInputs.get(path) === null ? current !== null : !current?.equals(nativeInputs.get(path)))) fail('E_CHANGED', `File changed while planning: ${path}`);
709
- if (current?.equals(content)) continue;
781
+ const wholeFileOwned = current && [previous?.files[path], derivedOutputs[path], derivedEntries[path], derivedFiles[path], derivedCatalogs[path], derivedConfigFiles[path]].some(digest => digest && digest === hash(current));
782
+ const existingBlock = current && (nativeManagedText(path) || canonicalEntrypoint(path) || path.endsWith('/.gitignore')) ? blockDigest(path, current) : null;
783
+ if (existingBlock && !wholeFileOwned && knownBlocks[path] !== existingBlock) conflicts.push(path);
784
+ if (current?.equals(content)) {
785
+ // Identical bytes do not prove ownership of a pre-existing catalogue.
786
+ // Refuse adoption: a later sync could otherwise overwrite user data.
787
+ if (!wholeFileOwned && (generatedInternal.has(path) || native(path) && !existingBlock && !mixedPaths.has(path) && !preserved.has(path))) conflicts.push(path);
788
+ continue;
789
+ }
710
790
  const managed = path === derivedPath && derivedBytes && current?.equals(derivedBytes)
711
- || [previous?.files[path], derivedOutputs[path], derivedEntries[path], derivedFiles[path]].some(digest => digest && hash(current || '') === digest)
791
+ || [previous?.files[path], derivedOutputs[path], derivedEntries[path], derivedFiles[path], derivedCatalogs[path], derivedConfigFiles[path]].some(digest => digest && hash(current || '') === digest)
712
792
  || knownBlocks[path] && blockDigest(path, current || Buffer.alloc(0)) === knownBlocks[path];
713
793
  const appendBlock = mixedPaths.has(path) || canonicalEntrypoint(path) || path === '.gitignore' || path.endsWith('/.gitignore');
714
794
  const conflict = current && path !== statePath && !managed && !appendBlock;
@@ -828,7 +908,9 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
828
908
  const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
829
909
  const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
830
910
  conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
831
- return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: (!conflicts.length || force) && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck, migration, fallbackChecks: [...fallbackChecks], fallbackOwnedPaths: ownedPaths, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
911
+ // A conflict means ownership or the managed bytes could not be proven.
912
+ // Backups do not authorize replacing user content, even with --force.
913
+ return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: !conflicts.length && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck, migration, fallbackChecks: [...fallbackChecks], fallbackOwnedPaths: ownedPaths, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
832
914
  } finally { rmSync(stage, { recursive: true, force: true }); }
833
915
  }
834
916
 
@@ -1019,7 +1101,7 @@ export function inspect(target) {
1019
1101
  if (content) issues.push({ code: 'modified', path });
1020
1102
  continue;
1021
1103
  }
1022
- if (!content || hash(content) !== digest && hash(content) !== derived.outputs[path] && hash(content) !== derived.files[path] && hash(content) !== derived.entries[path]) issues.push({ code: content ? 'modified' : 'missing', path });
1104
+ if (!content || hash(content) !== digest && hash(content) !== derived.outputs[path] && hash(content) !== derived.files[path] && hash(content) !== derived.entries[path] && hash(content) !== derived.catalogs[path] && !(path === '.pbai4devs/espelhos.conf' && hash(content) === derived.config)) issues.push({ code: content ? 'modified' : 'missing', path });
1023
1105
  }
1024
1106
  for (const [path, digest] of Object.entries({ ...state.blocks, ...derived.blocks })) {
1025
1107
  if (derived.removedBlocks.includes(path) && !Object.hasOwn(derived.blocks, path)) continue;