@pathbit/agentdevkit 0.0.23 → 0.0.24

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.
@@ -15,6 +15,7 @@ const assets = join(dirname(fileURLToPath(import.meta.url)), 'assets');
15
15
  const owner = 'testar-maturidade';
16
16
  const marker = '.maturity-case.json';
17
17
  const suiteMarker = '.maturity-suite.json';
18
+ const generatedCatalogs = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
18
19
  const outputIgnore = '# Exemplos gerados permanecem locais.\n*\n';
19
20
  const defaultOutput = root => join(root, 'examples/generated');
20
21
  const prepareResources = root => {
@@ -58,6 +59,7 @@ function createChild(root, parent, name, { allowExisting = false } = {}) {
58
59
  noLinks(root, join(parent, name));
59
60
  const output = stat(join(parent, name));
60
61
  if (!output?.isDirectory() || output.isSymbolicLink()) fail('Diretório de saída inválido.');
62
+ return output;
61
63
  }
62
64
  function ensureDirectories(root, target) {
63
65
  let current = root;
@@ -144,7 +146,7 @@ function copyAllowed(relativePath, directory = false) {
144
146
  return true;
145
147
  }
146
148
 
147
- export function copyAgentStructure(sourceRoot, destination) {
149
+ export function copyAgentStructure(sourceRoot, destination, { archiveCatalogs = false, archiveOverrides = new Set(), archiveEntrypoints = false } = {}) {
148
150
  const roots = ['.pbai4devs', ...registeredRoots(sourceRoot)];
149
151
  const stateFile = join(sourceRoot, stateDirectory(sourceRoot), 'agentdevkit.json');
150
152
  const stateInfo = stat(stateFile);
@@ -152,7 +154,7 @@ export function copyAgentStructure(sourceRoot, destination) {
152
154
  const installation = stateInfo ? json(stateFile) : null;
153
155
  if (stateInfo && (!installation || !installation.blocks || typeof installation.blocks !== 'object' || Array.isArray(installation.blocks))) fail('Estado da instalação deve registrar os blocos gerenciados.');
154
156
  function managedEntry(name) {
155
- if (!installation) return true;
157
+ if (!installation) return false;
156
158
  const expected = installation.blocks[name];
157
159
  if (typeof expected !== 'string') return false;
158
160
  const content = readFileSync(join(sourceRoot, name), 'utf8');
@@ -161,7 +163,7 @@ export function copyAgentStructure(sourceRoot, destination) {
161
163
  const start = content.indexOf(begin), finish = content.indexOf(end);
162
164
  return finish > start && digest(content.slice(start, finish + end.length)) === expected;
163
165
  }
164
- const files = []; const entrypoints = {}; let skipped = 0;
166
+ const files = []; const entrypoints = {}; const catalogSnapshots = []; const overriddenSnapshots = []; const seenSources = new Set(); let skipped = 0;
165
167
  function copy(file, targetFile = file) {
166
168
  const source = join(sourceRoot, file);
167
169
  const item = stat(source); if (!item) return;
@@ -171,6 +173,14 @@ export function copyAgentStructure(sourceRoot, destination) {
171
173
  mkdirSync(join(destination, targetFile), { recursive: true });
172
174
  for (const name of readdirSync(source).sort()) copy(join(file, name), join(targetFile, name));
173
175
  } else if (item.isFile()) {
176
+ seenSources.add(portable(file));
177
+ if (archiveCatalogs && generatedCatalogs.has(portable(file))) {
178
+ targetFile = `.agent-tests/testar-maturidade/original-catalogs/${basename(file)}`;
179
+ catalogSnapshots.push({ source: portable(file), snapshot: targetFile });
180
+ } else if (archiveOverrides.has(portable(file))) {
181
+ targetFile = `.agent-tests/testar-maturidade/original-files/${portable(file)}`;
182
+ overriddenSnapshots.push({ source: portable(file), snapshot: targetFile });
183
+ }
174
184
  const target = join(destination, targetFile); mkdirSync(dirname(target), { recursive: true }); copyFileSync(source, target); files.push(portable(targetFile));
175
185
  } else fail(`Arquivo não regular na origem: ${file}`);
176
186
  }
@@ -183,11 +193,29 @@ export function copyAgentStructure(sourceRoot, destination) {
183
193
  const selected = managedEntry(name) && stat(join(sourceRoot, template)) ? template : name;
184
194
  const info = stat(join(sourceRoot, selected));
185
195
  if (info && !info.isFile()) fail(`Entrada de agente deve ser arquivo regular: ${selected}`);
196
+ if (archiveEntrypoints && selected !== name) {
197
+ const snapshot = `.agent-tests/testar-maturidade/original-files/${name}`;
198
+ copy(name, snapshot); overriddenSnapshots.push({ source: name, snapshot });
199
+ }
186
200
  copy(selected, name);
187
201
  if (info) entrypoints[name] = selected;
188
202
  }
203
+ // A fixture can create files belonging to an unselected client; archive
204
+ // those source files as well, without traversing symbolic-link ancestors.
205
+ for (const path of archiveOverrides) if (!seenSources.has(path)) {
206
+ noLinks(sourceRoot, join(sourceRoot, path));
207
+ copy(path);
208
+ }
189
209
  const sourceDigest = digest(files.map(file => `${file}\0${digest(readFileSync(join(destination, file)))}`).join('\n'));
190
- return { roots, copiedFiles: files.length, skipped, files, sourceDigest, entrypoints, toolDefinitions: files.filter(file => /^\.pbai4devs\/tools\/[^/]+\/TOOL\.json$/.test(file)).length };
210
+ return { roots, copiedFiles: files.length, skipped, files, sourceDigest, entrypoints, catalogSnapshots, overriddenSnapshots, toolDefinitions: files.filter(file => /^\.pbai4devs\/tools\/[^/]+\/TOOL\.json$/.test(file)).length };
211
+ }
212
+
213
+ function fixtureOverrides(scenario, sourceRoot) {
214
+ const paths = ['README.md', '.pbai4devs/.gitignore', ...registeredRoots(sourceRoot).map(root => `${root}/.gitignore`), '.pbai4devs/state/configuracao/ESTADO.md', '.pbai4devs/estado/configuracao/ESTADO.md'];
215
+ if (scenario.planning !== 'none' || scenario.app) paths.push('.pbai4devs/state/README.md', '.pbai4devs/estado/README.md');
216
+ if (scenario.configured) paths.push('.dockerignore', '.env.example', 'Makefile');
217
+ if (scenario.layout === 'legacy') paths.push('Makefile', '.github/workflows/legacy.yml', '.vscode/settings.json');
218
+ return new Set(paths);
191
219
  }
192
220
 
193
221
  function planning(root, scenario) {
@@ -299,33 +327,41 @@ export function prepareExamples({ sourceRoot = projectRoot, outputRoot, scenario
299
327
  const parent = outputRoot === undefined ? base : dirname(base);
300
328
  ensureDirectories(sourceRoot, parent);
301
329
  const name = outputRoot === undefined ? `maturity-${randomUUID()}` : basename(base);
302
- createChild(sourceRoot, parent, name);
330
+ const created = createChild(sourceRoot, parent, name);
303
331
  outputRoot = join(parent, name);
304
- write(outputRoot, suiteMarker, JSON.stringify({ owner, version: 1 }, null, 2) + '\n');
305
- write(outputRoot, '.gitignore', outputIgnore);
306
- writeIndex(outputRoot, [], 'preparing');
332
+ const canonicalOutput = realpathSync(outputRoot);
333
+ const verifyOutput = (path = outputRoot) => {
334
+ noLinks(sourceRoot, path);
335
+ const current = stat(outputRoot);
336
+ if (!current?.isDirectory() || current.dev !== created.dev || current.ino !== created.ino || realpathSync(outputRoot) !== canonicalOutput) fail('Diretório de saída alterado durante a execução.');
337
+ };
338
+ const guarded = (path, operation) => { verifyOutput(path); const value = operation(); verifyOutput(path); return value; };
339
+ guarded(outputRoot, () => write(outputRoot, suiteMarker, JSON.stringify({ owner, version: 1 }, null, 2) + '\n'));
340
+ guarded(outputRoot, () => write(outputRoot, '.gitignore', outputIgnore));
341
+ guarded(outputRoot, () => writeIndex(outputRoot, [], 'preparing'));
307
342
  const examples = [];
308
343
  for (const scenario of scenarios) {
309
344
  const destination = join(outputRoot, scenario.id);
310
- mkdirSync(destination);
311
- write(destination, marker, JSON.stringify({ owner, version: 1, id: scenario.id }, null, 2) + '\n');
312
- const copied = copyAgentStructure(sourceRoot, destination);
313
- planning(destination, scenario);
314
- const app = application(destination, scenario);
315
- write(destination, 'README.md', document(scenario.title, `${scenario.note}\n\nEste é um exemplo gerado. Estados de aprovação/publicação são dados de teste.\nRevisão do gate é simulação contratual, não revisão humana.\n\nOs scripts dos agentes foram copiados, sem pacotes ou credenciais conhecidas.\nO filtro não detecta segredos em conteúdo arbitrário.\n${app.tests.length ? `\nPara repetir os testes: \`${scenario.layout === 'legacy' ? 'npm run test:legacy' : 'npm test'}\`.\nO harness executa os testes Node diretamente, sem instalar dependências.\nO teste abre HTTP em porta efêmera e encerra o servidor.\n` : '\nNão há aplicação; a prova real exercita os scripts copiados.\n'}`, `fase: ${scenario.expected}\n`));
316
- renderResources(destination);
317
- if (initializeGit) gitInit(destination);
345
+ guarded(outputRoot, () => mkdirSync(destination));
346
+ guarded(destination, () => write(destination, marker, JSON.stringify({ owner, version: 1, id: scenario.id }, null, 2) + '\n'));
347
+ const copied = guarded(destination, () => copyAgentStructure(sourceRoot, destination, { archiveCatalogs: true, archiveOverrides: fixtureOverrides(scenario, sourceRoot), archiveEntrypoints: true }));
348
+ guarded(destination, () => planning(destination, scenario));
349
+ const app = guarded(destination, () => application(destination, scenario));
350
+ guarded(destination, () => write(destination, 'README.md', document(scenario.title, `${scenario.note}\n\nEste é um exemplo gerado, não um teste no projeto original. Estados de aprovação/publicação são dados de teste.\nRevisão do gate é simulação contratual, não revisão humana.\nCatálogos originais e arquivos substituídos pela fixture ficam em \`.agent-tests/testar-maturidade/\`; os catálogos operacionais são regenerados.\n\nOs scripts dos agentes foram copiados, sem pacotes ou credenciais conhecidas.\nO filtro não detecta segredos em conteúdo arbitrário.\n${app.tests.length ? `\nPara repetir os testes: \`${scenario.layout === 'legacy' ? 'npm run test:legacy' : 'npm test'}\`.\nO harness executa os testes Node diretamente, sem instalar dependências.\nO teste abre HTTP em porta efêmera e encerra o servidor.\n` : '\nNão há aplicação; a prova real exercita os scripts copiados.\n'}`, `fase: ${scenario.expected}\n`)));
351
+ guarded(destination, () => renderResources(destination));
352
+ if (initializeGit) guarded(destination, () => gitInit(destination));
318
353
  examples.push({ ...scenario, root: destination, copied, application: app });
319
354
  }
320
- write(outputRoot, 'REPORT.json', JSON.stringify({ owner, version: 1, status: 'prepared', scenarios: [...ids], results: [] }, null, 2) + '\n');
321
- writeIndex(outputRoot, examples, 'prepared');
355
+ guarded(outputRoot, () => write(outputRoot, 'REPORT.json', JSON.stringify({ owner, version: 1, status: 'prepared', scenarios: [...ids], copies: examples.map(example => ({ id: example.id, catalogSnapshots: example.copied.catalogSnapshots, overriddenSnapshots: example.copied.overriddenSnapshots })), results: [] }, null, 2) + '\n'));
356
+ guarded(outputRoot, () => writeIndex(outputRoot, examples, 'prepared'));
357
+ Object.defineProperty(examples, 'verifyOutput', { value: verifyOutput });
322
358
  return examples;
323
359
  }
324
360
 
325
361
  function writeIndex(outputRoot, examples, status) {
326
362
  const rows = examples.map(item => `| [${item.id}](${item.id}/README.md) | ${item.expected} | ${item.status || 'prepared'} |`).join('\n');
327
363
  const progress = status === 'preparing' ? '\nPreparação em andamento nesta execução.\n' : '';
328
- write(outputRoot, 'README.md', document('Exemplos de maturidade', `Projetos sintéticos com cópias locais da estrutura de agentes.\nNenhum resultado certifica produção, revisão humana ou outros sistemas operacionais.\n${progress}\n| Cenário | Nível esperado | Resultado |\n| --- | --- | --- |${rows ? '\n' + rows : ''}\n\nRelatório: \`REPORT.json\`. Logs e evidências por projeto ficam em \`.agent-tests/\`.\nCada execução usa uma pasta nova e preserva as anteriores.\n`, `status: ${status}\n`));
364
+ write(outputRoot, 'README.md', document('Exemplos de maturidade', `Projetos sintéticos com cópias locais da estrutura de agentes; não são testes no projeto original.\nCatálogos operacionais são regenerados e suas cópias originais ficam em cada \`.agent-tests/testar-maturidade/\`.\nNenhum resultado certifica produção, revisão humana ou outros sistemas operacionais.\n${progress}\n| Cenário | Nível esperado | Resultado |\n| --- | --- | --- |${rows ? '\n' + rows : ''}\n\nRelatório: \`REPORT.json\`. Logs e evidências por projeto ficam em \`.agent-tests/\`.\nCada execução usa uma pasta nova e preserva as anteriores.\n`, `status: ${status}\n`));
329
365
  }
330
366
 
331
367
  function hashes(root, files) { return Object.fromEntries(files.map(file => [file, digest(readFileSync(join(root, file)))])); }
@@ -399,13 +435,14 @@ function resourceLifecycle(example, run) {
399
435
  if (readFileSync(join(root, catalogPath), 'utf8') !== before.catalog || readFileSync(join(root, registryPath), 'utf8') !== before.registry) fail('O ciclo não restaurou os catálogos originais.');
400
436
  const corrupted = '{"tampered":true}\n'; write(root, catalogPath, corrupted);
401
437
  run(sync, ['--check'], 1, 'catalog-drift-detected');
402
- const repair = run(sync, ['--force'], 0, 'catalog-drift-repaired');
403
- const backup = repair.output.match(/Backup local: (.+)/)?.[1]?.trim();
404
- if (!backup || readFileSync(join(root, backup, catalogPath), 'utf8') !== corrupted) fail('Correção forçada não preservou backup do JSON alterado.');
438
+ const blocked = run(sync, ['--force'], 2, 'catalog-drift-preserved');
439
+ if (!blocked.output.includes('Conteudo proprio preservado')) fail('A colisão não informou a preservação do catálogo.');
440
+ if (readFileSync(join(root, catalogPath), 'utf8') !== corrupted) fail('Sincronização alterou o JSON sintético sem ownership.');
441
+ write(root, catalogPath, before.catalog); // Restore only the content this fixture deliberately changed.
405
442
  rmSync(join(root, catalogPath)); run(sync, ['--check'], 1, 'catalog-missing-detected');
406
443
  run(sync, [], 0, 'catalog-missing-repaired'); run(sync, ['--check'], 0, 'lifecycle-clean');
407
444
  if (readFileSync(join(root, catalogPath), 'utf8') !== before.catalog || readFileSync(join(root, registryPath), 'utf8') !== before.registry) fail('Catálogos não foram restaurados após corrupção/remoção.');
408
- return { status: 'passed', operations: ['create', 'edit-description', 'rename', 'delete', 'corrupt-json', 'remove-json'], clients: rows.length, backup, sourceRestored: true };
445
+ return { status: 'passed', operations: ['create', 'edit-description', 'rename', 'delete', 'protect-modified-json', 'remove-json'], clients: rows.length, sourceRestored: true };
409
446
  }
410
447
 
411
448
  function reviewContract(example, run, proof) {
@@ -444,7 +481,7 @@ function reviewContract(example, run, proof) {
444
481
  export function testExample(example) {
445
482
  const commands = []; const logRoot = '.agent-tests/maturity';
446
483
  const run = runner(example.root, logRoot, commands);
447
- const result = { id: example.id, expected: example.expected, status: 'failed', copied: { files: example.copied.copiedFiles, roots: example.copied.roots, skipped: example.copied.skipped, sourceDigest: example.copied.sourceDigest, entrypoints: example.copied.entrypoints }, commands, limits: ['Executado somente no SO/runtime deste host.', 'Estado de produto e revisão do gate são dados sintéticos.', 'Build Docker, UI e implantação não são exercitados.', 'Cópia exclui credenciais por nomes conhecidos, sem varredura semântica de segredos.'] };
484
+ const result = { id: example.id, expected: example.expected, status: 'failed', copied: { files: example.copied.copiedFiles, roots: example.copied.roots, skipped: example.copied.skipped, sourceDigest: example.copied.sourceDigest, entrypoints: example.copied.entrypoints, catalogSnapshots: example.copied.catalogSnapshots, overriddenSnapshots: example.copied.overriddenSnapshots }, commands, limits: ['Cenário sintético: não testa o projeto original no local.', 'Executado somente no SO/runtime deste host.', 'Estado de produto e revisão do gate são dados sintéticos.', 'Build Docker, UI e implantação não são exercitados.', 'Cópia exclui credenciais por nomes conhecidos, sem varredura semântica de segredos.'] };
448
485
  const preserved = hashes(example.root, example.application.protected);
449
486
  try {
450
487
  const sync = '.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
@@ -493,11 +530,14 @@ export function testExample(example) {
493
530
 
494
531
  export function runExamples(options = {}) {
495
532
  const examples = prepareExamples(options);
496
- const results = examples.map(example => { const result = testExample(example); console.log(`${example.id}=${result.status}${result.error ? ': ' + result.error : ''}`); return result; });
533
+ const results = examples.map(example => { examples.verifyOutput(example.root); const result = testExample(example); examples.verifyOutput(example.root); console.log(`${example.id}=${result.status}${result.error ? ': ' + result.error : ''}`); return result; });
497
534
  const outputRoot = dirname(examples[0].root);
498
535
  const report = { owner, version: 1, status: results.every(item => item.status === 'passed') ? 'passed' : 'failed', platform: process.platform, arch: process.arch, node: process.version, results, limits: ['Provas reais somente das operações descritas e APIs locais.', 'Pareceres do gate são simulações explícitas, sem revisão humana.', 'Não certifica produção nem sistemas operacionais não executados.'] };
536
+ examples.verifyOutput();
499
537
  write(outputRoot, 'REPORT.json', JSON.stringify(report, null, 2) + '\n');
538
+ examples.verifyOutput();
500
539
  writeIndex(outputRoot, examples.map(example => ({ ...example, status: results.find(result => result.id === example.id).status })), report.status);
540
+ examples.verifyOutput();
501
541
  return { outputRoot, report };
502
542
  }
503
543
 
@@ -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');
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.24 — 2026-11-14
8
+
9
+ - Preserva catálogos, configuração e blocos existentes sem comprovação de
10
+ propriedade, inclusive ao usar `--force` ou sincronização direta.
11
+ - Mantém o estado coerente quando a sincronização atualiza saídas gerenciadas.
12
+ - Acrescenta testes de colisão, arquivos idênticos e preservação do índice Git.
13
+
7
14
  ## 0.0.23 — 2026-11-13
8
15
 
9
16
  - Corrige a resolução de destinos inexistentes diretamente sob a raiz.
@@ -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.24",
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',