@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.
@@ -9,8 +9,8 @@ import { buildCatalog } from '../lib/catalog.mjs';
9
9
  import { resourcePath } from '../lib/resource-path.mjs';
10
10
  import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
11
11
  import { nativePathBoundary } from '../lib/native-paths.mjs';
12
- import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
13
- import { mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
12
+ import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, extractManagedBlock, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
13
+ import { extractTomlManagedBlock, mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
14
14
  import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, planUntrack, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
15
15
  import { stateDirectory } from '../lib/state-path.mjs';
16
16
 
@@ -90,7 +90,7 @@ function replaceJsonMember(bytes, member, value) {
90
90
  // Compare owned fields structurally, preserving all other JSON bytes.
91
91
  function derivedContent(original, previous, next) {
92
92
  if (!original) return JSON.stringify(next, null, 2) + '\n';
93
- const keys = ['schemaVersion', 'entries', 'outputs', 'files', 'blocks', 'removedBlocks', 'removedFiles'];
93
+ const keys = ['schemaVersion', 'entries', 'outputs', 'files', 'blocks', 'removedBlocks', 'removedFiles', 'catalogs', 'config'];
94
94
  const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
95
95
  ? Object.fromEntries(Object.keys(value).sort(compare).map(key => [key, canonical(value[key])]))
96
96
  : Array.isArray(value) ? value.map(canonical) : value;
@@ -247,6 +247,11 @@ export function synchronize({ root = projectRoot, check = false, force = false,
247
247
  if (previousEntries?.schemaVersion !== 1 || !previousEntries.entries || typeof previousEntries.entries !== 'object' || Array.isArray(previousEntries.entries) || Object.entries(previousEntries.entries).some(([name, hash]) => !canonicalEntrypoint(name) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de entradas derivadas invalido.');
248
248
  const previousOutputs = previousEntries.outputs ?? {};
249
249
  if (!previousOutputs || typeof previousOutputs !== 'object' || Array.isArray(previousOutputs) || Object.entries(previousOutputs).some(([path, hash]) => !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/(skills|rules|agents)\.json$/.test(path) || reserved.has(path.split('/')[0].toLowerCase()) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de saidas derivadas invalido.');
250
+ const previousCatalogs = previousEntries.catalogs ?? {};
251
+ const catalogPaths = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
252
+ if (!previousCatalogs || typeof previousCatalogs !== 'object' || Array.isArray(previousCatalogs) || Object.entries(previousCatalogs).some(([path, hash]) => !catalogPaths.has(path) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de catalogos derivados invalido.');
253
+ for (const path of Object.keys(previousCatalogs)) safe(path);
254
+ if (previousEntries.config !== undefined && (typeof previousEntries.config !== 'string' || !/^[a-f0-9]{64}$/.test(previousEntries.config))) fail('Registro de configuracao derivada invalido.');
250
255
  const validResourcePath = path => {
251
256
  if (typeof path !== 'string' || !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/.+/.test(path)) return false;
252
257
  const [root, scope] = path.split('/');
@@ -254,7 +259,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
254
259
  && !['readme.md', 'instructions.md'].includes(posix.basename(path).toLowerCase())
255
260
  && (/\.(md|mdc)$/i.test(path) || path.startsWith('.gemini/commands/') && path.endsWith('.toml'));
256
261
  };
257
- const validIgnoreBlock = path => typeof path === 'string' && /^\.[A-Za-z0-9][A-Za-z0-9._-]*\/\.gitignore$/.test(path) && !reserved.has(path.split('/')[0].toLowerCase());
262
+ const validIgnoreBlock = path => path === '.pbai4devs/.gitignore' || typeof path === 'string' && /^\.[A-Za-z0-9][A-Za-z0-9._-]*\/\.gitignore$/.test(path) && !reserved.has(path.split('/')[0].toLowerCase());
258
263
  for (const [name, values] of [['files', previousEntries.files ?? {}], ['blocks', previousEntries.blocks ?? {}]]) {
259
264
  if (!values || typeof values !== 'object' || Array.isArray(values) || Object.entries(values).some(([path, hash]) => !(validResourcePath(path) || name === 'blocks' && (canonicalEntrypoint(path) || validIgnoreBlock(path))) || !/^[a-f0-9]{64}$/.test(hash))) fail(`Registro de ${name} derivados invalido.`);
260
265
  for (const path of Object.keys(values)) safe(canonicalEntrypoint(path) ? resolveEntrypoint(root, canonicalEntrypoint(path)) : path);
@@ -475,11 +480,23 @@ export function synchronize({ root = projectRoot, check = false, force = false,
475
480
  }
476
481
  const registries = new Map();
477
482
  const discoveryOutputs = {};
483
+ const catalogOutputs = {};
478
484
  for (const [target, content] of built.outputs) {
479
485
  const info = stat(safe(target));
480
486
  if (info && !info.isFile()) fail(`Registro nao e arquivo: ${target}`);
481
487
  const current = info ? readFileSync(absolute(target)) : null;
482
488
  captureInput(target, current);
489
+ const currentHash = current && createHash('sha256').update(current).digest('hex');
490
+ const known = current && [installed?.files?.[target], previousCatalogs[target]].some(digest => digest && digest === currentHash);
491
+ if (current && !known) {
492
+ // Identical bytes do not establish ownership. An unowned catalogue can
493
+ // remain if already current, but it cannot be adopted or regenerated.
494
+ remember(target, 'existing-catalog');
495
+ if (current.toString('utf8') !== content) { conflicts.add(target); protectedConflicts.add(target); }
496
+ continue;
497
+ }
498
+ const nextHash = createHash('sha256').update(content).digest('hex');
499
+ if (installed?.files?.[target] !== nextHash) catalogOutputs[target] = nextHash;
483
500
  if (!current || current.toString('utf8') !== content) {
484
501
  registries.set(target, content);
485
502
  if (force && info) backup.add(target);
@@ -533,10 +550,22 @@ export function synchronize({ root = projectRoot, check = false, force = false,
533
550
  merged = mergeGitignore(current, patterns);
534
551
  }
535
552
  catch (error) { fail(`${target}: ${error.message}`); }
536
- if (target !== '.pbai4devs/.gitignore') {
537
- const start = merged.text.indexOf(ignoreBegin), finish = merged.text.indexOf(ignoreEnd);
538
- if (start >= 0 && finish > start) managedBlocks[target] = createHash('sha256').update(merged.text.slice(start, finish + ignoreEnd.length)).digest('hex');
553
+ if (merged.hasBlock) {
554
+ const start = current.indexOf(ignoreBegin), finish = current.indexOf(ignoreEnd);
555
+ const blockHash = start >= 0 && finish > start ? createHash('sha256').update(current.slice(start, finish + ignoreEnd.length)).digest('hex') : null;
556
+ const fileHash = createHash('sha256').update(current).digest('hex');
557
+ const known = [installed?.blocks?.[target], previousEntries.blocks?.[target]].some(hash => hash && hash === blockHash)
558
+ || priorFiles[target] && priorFiles[target] === fileHash;
559
+ if (!known) {
560
+ // A marker or identical contents is not proof that this block belongs
561
+ // to the kit. Do not adopt, replace or remove it even with --force.
562
+ remember(target, 'existing-ignore-block');
563
+ if (merged.text !== current) { conflicts.add(target); protectedConflicts.add(target); }
564
+ continue;
565
+ }
539
566
  }
567
+ const start = merged.text.indexOf(ignoreBegin), finish = merged.text.indexOf(ignoreEnd);
568
+ if (start >= 0 && finish > start) managedBlocks[target] = createHash('sha256').update(merged.text.slice(start, finish + ignoreEnd.length)).digest('hex');
540
569
  if (merged.text !== current) {
541
570
  ignoreFiles.set(target, merged.text);
542
571
  if (force && info && merged.hasBlock) backup.add(target);
@@ -568,6 +597,10 @@ export function synchronize({ root = projectRoot, check = false, force = false,
568
597
  if (!command) fail(`Comando Gemini exige COMMAND.md: ${source}`);
569
598
  const current = mixed ? targetBytes : null;
570
599
  const merged = mixed ? mergeTomlContent(current, `@{${source}}`, { expectedHash: priorBlocks[target] }) : null;
600
+ if (current && !priorBlocks[target] && priorFiles[target] !== createHash('sha256').update(current).digest('hex') && extractTomlManagedBlock(current)) {
601
+ // An identical block can remain, but its bytes are not ownership proof.
602
+ remember(target, 'existing-managed-block'); expected.set(target, current); continue;
603
+ }
571
604
  const content = merged ? merged.text : `${tomlMarker}\ndescription = ${JSON.stringify(command.description)}\nprompt = ${JSON.stringify(`@{${source}}\n\n{{args}}`)}\n`;
572
605
  if (mixed) { captureInput(target, current); managedBlocks[target] = merged.sha256; remember(target, 'existing-file-extended'); }
573
606
  else rememberGenerated(target, content);
@@ -588,6 +621,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
588
621
  const inclusion = ruleInclusion ? `@[origem](${link(target, source)})` : `Leia e siga [origem](${link(target, source)}).`;
589
622
  const current = mixed ? targetBytes : null;
590
623
  const merged = mixed ? mergeManagedContent(current, inclusion, { expectedHash: priorBlocks[target] }) : null;
624
+ if (current && !priorBlocks[target] && priorFiles[target] !== createHash('sha256').update(current).digest('hex') && extractManagedBlock(current)) {
625
+ remember(target, 'existing-managed-block'); expected.set(target, current); continue;
626
+ }
591
627
  const content = merged ? merged.text : `${frontmatter}\n${title}\n\n${marker}\n${inclusion}\n`;
592
628
  if (mixed) { captureInput(target, current); managedBlocks[target] = merged.sha256; remember(target, 'existing-file-extended'); }
593
629
  else rememberGenerated(target, content);
@@ -644,6 +680,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
644
680
  const previousBlockHash = priorBlocks[name] ?? Object.entries(priorBlocks).find(([path]) => canonicalEntrypoint(path) === canonical)?.[1];
645
681
  if (current && (previousBlockHash || createHash('sha256').update(current).digest('hex') !== previousHash)) {
646
682
  const merged = mergeManagedContent(current, entrypointReference(root, canonical), { expectedHash: previousBlockHash });
683
+ if (!previousBlockHash && previousHash !== createHash('sha256').update(current).digest('hex') && extractManagedBlock(current)) {
684
+ remember(name, 'existing-managed-block'); continue;
685
+ }
647
686
  captureInput(name, current);
648
687
  managedBlocks[name] = merged.sha256;
649
688
  rootEntries.set(name, merged.text);
@@ -682,8 +721,20 @@ export function synchronize({ root = projectRoot, check = false, force = false,
682
721
  removedBlocks.add(target);
683
722
  remember(target, 'existing-file-extended');
684
723
  }
724
+ const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
725
+ const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
726
+ const configChanged = !configExists || configText !== finalConfig;
727
+ const configHash = configExists ? createHash('sha256').update(readFileSync(absolute(config))).digest('hex') : null;
728
+ const configKnown = configExists && [installed?.files?.[config], previousEntries.config].some(hash => hash && hash === configHash);
729
+ if (configExists && !configKnown) {
730
+ remember(config, 'existing-configuration');
731
+ if (configChanged) { conflicts.add(config); protectedConflicts.add(config); }
732
+ }
685
733
  const next = { schemaVersion: 1, entries: nextEntries };
686
734
  if (Object.keys(discoveryOutputs).length) next.outputs = discoveryOutputs;
735
+ if (Object.keys(catalogOutputs).length) next.catalogs = catalogOutputs;
736
+ const nextConfigHash = createHash('sha256').update(finalConfig).digest('hex');
737
+ if ((!configExists || configKnown) && installed?.files?.[config] !== nextConfigHash) next.config = nextConfigHash;
687
738
  if (Object.keys(generatedFiles).length) next.files = generatedFiles;
688
739
  const derivedBlocks = Object.fromEntries(Object.entries(managedBlocks).filter(([path, hash]) => installed?.blocks?.[path] !== hash || Object.hasOwn(previousEntries.blocks ?? {}, path)));
689
740
  if (Object.keys(derivedBlocks).length) next.blocks = derivedBlocks;
@@ -693,7 +744,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
693
744
  for (const path of remove) if (validResourcePath(path) && installed?.files?.[path] && !expected.has(path)) effectiveRemovedFiles[path] = installed.files[path];
694
745
  if (Object.keys(effectiveRemovedFiles).length) next.removedFiles = Object.fromEntries(Object.entries(effectiveRemovedFiles).sort(([a], [b]) => compare(a, b)));
695
746
  const registryContent = derivedContent(registryBytes, previousEntries, next);
696
- if (recordDerivedState && (Object.keys(nextEntries).length || Object.keys(discoveryOutputs).length || Object.keys(generatedFiles).length || Object.keys(derivedBlocks).length || removedBlocks.size || Object.keys(effectiveRemovedFiles).length || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== registryContent)) registries.set(entryRegistry, registryContent);
747
+ if (recordDerivedState && (Object.keys(nextEntries).length || Object.keys(discoveryOutputs).length || Object.keys(catalogOutputs).length || next.config || Object.keys(generatedFiles).length || Object.keys(derivedBlocks).length || removedBlocks.size || Object.keys(effectiveRemovedFiles).length || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== registryContent)) registries.set(entryRegistry, registryContent);
697
748
  const ownedOutputs = sorted([...targets, ...built.outputs.keys(), '.pbai4devs/espelhos.conf', ...rootEntries.keys(), ...(recordDerivedState && (stat(safe(entryRegistry)) || registries.has(entryRegistry)) ? [entryRegistry] : []),
698
749
  ...[...discoveries].flatMap(agent => ['skills', 'rules', 'agents'].map(kind => `${agent}/${kind}.json`)),
699
750
  ...[...ignoreTemplates.keys()].filter(target => {
@@ -709,9 +760,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
709
760
  const removals = topPaths(remove);
710
761
  const backups = topPaths(backup);
711
762
  const rootEntryChanges = [...rootEntries].filter(([name, content]) => !stat(absolute(name)) || readFileSync(absolute(name), 'utf8') !== content);
712
- const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
713
- const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
714
- const configChanged = !configExists || configText !== finalConfig;
715
763
  // Every planned write/removal may cover a different staged version. Compare
716
764
  // the worktree bytes with the index before touching any path, even in force
717
765
  // mode, and repeat immediately before applying the transaction.
@@ -0,0 +1,251 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createHash } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { synchronize } from '../SCRIPT.mjs';
10
+ import { entrypointReference, mergeManagedContent } from '../../lib/managed-content.mjs';
11
+ import { mergeTomlContent } from '../../lib/managed-toml.mjs';
12
+
13
+ const catalogs = ['catalog.json', 'skills.json', 'agents.json', 'rules.json'].map(name => `.pbai4devs/${name}`);
14
+ const state = '.pbai4devs/state/derived.json';
15
+ const original = '.pbai4devs/skills/demo/SKILL.md';
16
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
17
+ const get = (root, path) => readFileSync(join(root, path));
18
+ const put = (root, path, bytes) => { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), bytes); };
19
+ const gitEnvironment = root => ({ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))), GIT_CONFIG_GLOBAL: join(root, '.git-config-ausente'), GIT_CONFIG_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' });
20
+ const sync = (root, options = {}) => {
21
+ // synchronize is synchronous and launches Git internally. Isolate only for
22
+ // this call so an inherited GIT_INDEX_FILE/GIT_DIR cannot touch another repo.
23
+ const saved = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^GIT_/i.test(key)));
24
+ for (const key of Object.keys(saved)) delete process.env[key];
25
+ Object.assign(process.env, { GIT_CONFIG_GLOBAL: join(root, '.git-config-ausente'), GIT_CONFIG_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' });
26
+ try { return synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {}, ...options }); }
27
+ finally {
28
+ for (const key of Object.keys(process.env)) if (/^GIT_/i.test(key)) delete process.env[key];
29
+ Object.assign(process.env, saved);
30
+ }
31
+ };
32
+ function git(root, ...args) {
33
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', env: gitEnvironment(root) });
34
+ assert.equal(result.status, 0, result.stderr);
35
+ }
36
+ function fixture(t) {
37
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'adk-catalog-ownership-')));
38
+ t.after(() => rmSync(root, { recursive: true, force: true }));
39
+ git(root, 'init', '-q');
40
+ put(root, '.gitignore', 'node_modules/\n');
41
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\n');
42
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache\n/*\n!/.gitignore\n{{ALLOWLIST}}\n');
43
+ put(root, '.pbai4devs/templates/gitignore/clients.json', '{}\n');
44
+ put(root, '.pbai4devs/templates/gitignore/shared.ignore', '# Shared\n/tools/*/.local/\n');
45
+ put(root, original, '---\nname: demo\ndescription: Demo.\n---\n\n# Demo\n');
46
+ git(root, 'add', '-f', '.gitignore');
47
+ return root;
48
+ }
49
+
50
+ for (const path of catalogs) for (const force of [false, true]) {
51
+ test(`catálogo editado permanece intacto na sincronização direta: ${path}, force=${force}`, t => {
52
+ const root = fixture(t);
53
+ assert.equal(sync(root).exitCode, 0);
54
+ put(root, path, '{"human":"preservar"}\n');
55
+ git(root, 'add', '-f', '--', path);
56
+ const before = Object.fromEntries([path, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
57
+ assert.notEqual(sync(root, { check: true, force }).exitCode, 0);
58
+ assert.throws(() => sync(root, { force }), /Conteudo proprio preservado/);
59
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
60
+ });
61
+ }
62
+
63
+ test('catálogo anterior idêntico não é assumido como próprio sem estado', t => {
64
+ const root = fixture(t);
65
+ assert.equal(sync(root).exitCode, 0);
66
+ const recorded = JSON.parse(get(root, state));
67
+ delete recorded.catalogs;
68
+ put(root, state, `${JSON.stringify(recorded, null, 2)}\n`);
69
+ const before = Object.fromEntries([...catalogs, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
70
+ const result = sync(root, { force: true });
71
+ for (const path of catalogs) assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-catalog'), path);
72
+ assert.equal(JSON.parse(get(root, state)).catalogs, undefined);
73
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
74
+ put(root, original, '---\nname: demo\ndescription: Mudança humana.\n---\n\n# Demo\n');
75
+ assert.throws(() => sync(root, { force: true }), /Conteudo proprio preservado/);
76
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
77
+ });
78
+
79
+ test('sincronizações diretas sucessivas registram hashes dos catálogos criados', t => {
80
+ const root = fixture(t);
81
+ assert.equal(sync(root).exitCode, 0);
82
+ for (const path of catalogs) assert.equal(JSON.parse(get(root, state)).catalogs[path], digest(get(root, path)), path);
83
+ put(root, original, '---\nname: demo\ndescription: Nova descrição.\n---\n\n# Demo\n');
84
+ assert.equal(sync(root).exitCode, 0);
85
+ for (const path of catalogs) assert.equal(JSON.parse(get(root, state)).catalogs[path], digest(get(root, path)), path);
86
+ assert.equal(sync(root, { check: true }).exitCode, 0);
87
+ });
88
+
89
+ test('registro de catálogo desconhecido ou hash inválido bloqueia antes de alterar arquivos', t => {
90
+ const root = fixture(t);
91
+ assert.equal(sync(root).exitCode, 0);
92
+ const registry = JSON.parse(get(root, state));
93
+ registry.catalogs['.pbai4devs/other.json'] = 'a'.repeat(64);
94
+ put(root, state, JSON.stringify(registry));
95
+ const before = Object.fromEntries([...catalogs, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
96
+ assert.throws(() => sync(root, { force: true }), /Registro de catalogos derivados invalido/);
97
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
98
+ assert.ok(existsSync(join(root, original)));
99
+ });
100
+
101
+ for (const path of ['.pbai4devs/.gitignore', '.claude/.gitignore']) for (const force of [false, true]) {
102
+ test(`bloco ignore editado permanece intacto: ${path}, force=${force}`, t => {
103
+ const root = fixture(t);
104
+ assert.equal(sync(root).exitCode, 0);
105
+ const content = get(root, path).toString('utf8').replace('# BEGIN AGENT IGNORE', '# BEGIN AGENT IGNORE\n# Regra humana\n!/conteudo-humano/');
106
+ put(root, path, content);
107
+ git(root, 'add', '-f', '--', path);
108
+ const before = Object.fromEntries([path, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
109
+ assert.notEqual(sync(root, { check: true, force }).exitCode, 0);
110
+ assert.throws(() => sync(root, { force }), /Conteudo proprio preservado/);
111
+ for (const [name, bytes] of Object.entries(before)) assert.deepEqual(get(root, name), bytes, name);
112
+ });
113
+ }
114
+
115
+ for (const path of ['.pbai4devs/.gitignore', '.claude/.gitignore']) {
116
+ test(`bloco ignore igual sem comprovante não é adotado: ${path}`, t => {
117
+ const root = fixture(t);
118
+ assert.equal(sync(root).exitCode, 0);
119
+ const registry = JSON.parse(get(root, state));
120
+ delete registry.blocks[path];
121
+ put(root, state, JSON.stringify(registry, null, 2) + '\n');
122
+ const originalIgnore = get(root, path);
123
+ const result = sync(root, { force: true });
124
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-ignore-block'));
125
+ assert.equal(JSON.parse(get(root, state)).blocks?.[path], undefined);
126
+ assert.deepEqual(get(root, path), originalIgnore);
127
+ const template = path.startsWith('.pbai4devs/') ? 'shared.ignore' : 'agents.ignore';
128
+ const templatePath = `.pbai4devs/templates/gitignore/${template}`;
129
+ put(root, templatePath, Buffer.concat([get(root, templatePath), Buffer.from('/novo-item/\n')]));
130
+ assert.throws(() => sync(root, { force: true }), /Conteudo proprio preservado/);
131
+ assert.deepEqual(get(root, path), originalIgnore);
132
+ });
133
+ }
134
+
135
+ test('bloco idêntico em AGENTS.md sem registro não é adotado', t => {
136
+ const root = fixture(t), path = 'AGENTS.md';
137
+ put(root, '.pbai4devs/templates/entrypoints/AGENTS.md', '# Instruções\n');
138
+ const content = mergeManagedContent('# Instruções humanas\n', entrypointReference(root, path)).text;
139
+ put(root, path, content);
140
+ const result = sync(root, { force: true, generateEntrypoints: true });
141
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-managed-block'));
142
+ assert.equal(get(root, path).toString('utf8'), content);
143
+ const registry = JSON.parse(get(root, state));
144
+ assert.equal(registry.blocks?.[path], undefined);
145
+ assert.equal(registry.entries?.[path], undefined);
146
+ assert.equal(sync(root, { force: true, generateEntrypoints: true }).exitCode, 0);
147
+ assert.equal(get(root, path).toString('utf8'), content);
148
+ });
149
+
150
+ for (const kind of ['markdown', 'toml']) test(`bloco nativo ${kind} idêntico sem registro não é adotado`, t => {
151
+ const root = fixture(t);
152
+ const path = kind === 'markdown' ? '.claude/skills/demo/SKILL.md' : '.gemini/commands/demo.toml';
153
+ let content;
154
+ if (kind === 'markdown') {
155
+ content = mergeManagedContent('# Instruções humanas\n', 'Leia e siga [origem](../../../.pbai4devs/skills/demo/SKILL.md).').text;
156
+ } else {
157
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\nagente|.gemini|plano\n');
158
+ put(root, '.pbai4devs/commands/demo/COMMAND.md', '---\ndescription: Demo.\n---\n\n# Demo\n');
159
+ content = mergeTomlContent('description = "Humano"\nprompt = "Meu prompt"\n', '@{.pbai4devs/commands/demo/COMMAND.md}').text;
160
+ }
161
+ put(root, path, content);
162
+ const result = sync(root, { force: true });
163
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-managed-block'));
164
+ assert.equal(get(root, path).toString('utf8'), content);
165
+ const registry = JSON.parse(get(root, state));
166
+ assert.equal(registry.blocks?.[path], undefined);
167
+ assert.equal(registry.files?.[path], undefined);
168
+ assert.equal(sync(root, { force: true }).exitCode, 0);
169
+ assert.equal(get(root, path).toString('utf8'), content);
170
+ });
171
+
172
+ for (const force of [false, true]) test(`configuração humana não é alterada nem adotada: force=${force}`, t => {
173
+ const root = fixture(t), path = '.pbai4devs/espelhos.conf';
174
+ const identical = get(root, path);
175
+ const result = sync(root, { force });
176
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-configuration'));
177
+ assert.equal(JSON.parse(get(root, state)).config, undefined);
178
+ assert.deepEqual(get(root, path), identical);
179
+ put(root, path, Buffer.concat([identical, Buffer.from('documento|manual-humano\n')]));
180
+ git(root, 'add', '-f', '--', path);
181
+ const before = Object.fromEntries([path, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
182
+ assert.notEqual(sync(root, { check: true, force }).exitCode, 0);
183
+ assert.throws(() => sync(root, { force }), /Conteudo proprio preservado/);
184
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
185
+ });
186
+
187
+ test('overlay de configuração inválido não autoriza escrita', t => {
188
+ const root = fixture(t);
189
+ assert.equal(sync(root).exitCode, 0);
190
+ const registry = JSON.parse(get(root, state));
191
+ registry.config = 'nao-e-sha256';
192
+ put(root, state, JSON.stringify(registry));
193
+ const before = get(root, state);
194
+ assert.throws(() => sync(root, { force: true }), /Registro de configuracao derivada invalido/);
195
+ assert.deepEqual(get(root, state), before);
196
+ });
197
+
198
+ test('CLI reconhece catálogos da sincronização direta e preserva atualização de ignore', t => {
199
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'adk-cli-sync-ownership-')));
200
+ t.after(() => rmSync(root, { recursive: true, force: true }));
201
+ git(root, 'init', '-q');
202
+ put(root, '.gitignore', 'node_modules/\n');
203
+ put(root, 'AGENTS.md', '# Instruções existentes\n');
204
+ put(root, 'staged.txt', 'Conteúdo staged humano.\n');
205
+ git(root, 'add', '-f', '.gitignore', 'AGENTS.md', 'staged.txt');
206
+ const initial = Object.fromEntries(['.gitignore', '.git/index', 'staged.txt'].map(path => [path, get(root, path)]));
207
+ const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../bin/agentdevkit.mjs');
208
+ function runCli(...args) {
209
+ const result = spawnSync(process.execPath, [cliPath, ...args, '--target', root, '--json'], {
210
+ cwd: root, env: gitEnvironment(root), encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024,
211
+ });
212
+ let output; try { output = JSON.parse(result.stdout); } catch { assert.fail(`Resposta CLI inválida: ${result.stderr} ${result.stdout}`); }
213
+ assert.equal(result.status, 0, `${args.join(' ')}: ${result.stderr} ${result.stdout}`);
214
+ return output;
215
+ }
216
+ function direct() {
217
+ const result = spawnSync(process.execPath, [join(root, '.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs')], {
218
+ cwd: root, env: gitEnvironment(root), encoding: 'utf8', timeout: 90000,
219
+ });
220
+ assert.equal(result.status, 0, result.stderr || result.stdout);
221
+ }
222
+ runCli('init', '--agents', 'codex');
223
+ assert.ok(get(root, 'AGENTS.md').toString('utf8').startsWith('# Instruções existentes\n'));
224
+ put(root, '.pbai4devs/skills/projeto/human-extra/SKILL.md', '---\nname: human-extra\ndescription: Minha skill.\n---\n\n# Minha skill\n');
225
+ direct(); direct();
226
+ assert.equal(runCli('doctor').ok, true);
227
+ assert.equal(runCli('sync', '--check').ok, true);
228
+ assert.equal(runCli('update').ok, true);
229
+ assert.equal(runCli('sync', '--check').ok, true);
230
+ // Simule uma versão legada que registrou legitimamente este manifesto.
231
+ const config = '.pbai4devs/espelhos.conf', installed = '.pbai4devs/state/agentdevkit.json';
232
+ put(root, config, Buffer.concat([get(root, config), Buffer.from('documento|legado-comprovado\n')]));
233
+ const previous = JSON.parse(get(root, installed));
234
+ previous.files[config] = digest(get(root, config));
235
+ put(root, installed, JSON.stringify(previous, null, 2) + '\n');
236
+ direct();
237
+ assert.doesNotMatch(get(root, config).toString('utf8'), /documento\|legado-comprovado/);
238
+ assert.equal(JSON.parse(get(root, state)).config, digest(get(root, config)));
239
+ assert.equal(runCli('doctor').ok, true);
240
+ assert.equal(runCli('sync', '--check').ok, true);
241
+ assert.equal(runCli('update').ok, true);
242
+ const template = '.pbai4devs/templates/gitignore/shared.ignore';
243
+ const humanTemplate = Buffer.concat([get(root, template), Buffer.from('\n/artefato-humano/\n')]);
244
+ put(root, template, humanTemplate);
245
+ direct();
246
+ assert.match(get(root, '.pbai4devs/.gitignore').toString('utf8'), /\/artefato-humano\//);
247
+ assert.equal(runCli('update').ok, true);
248
+ assert.deepEqual(get(root, template), humanTemplate);
249
+ assert.equal(runCli('sync', '--check').ok, true);
250
+ for (const [path, content] of Object.entries(initial)) assert.deepEqual(get(root, path), content, path);
251
+ });
@@ -14,6 +14,7 @@ import './HUMAN_OWNERSHIP.mjs';
14
14
  import './COEXISTENCE.mjs';
15
15
  import './MANAGED_CONTENT.mjs';
16
16
  import './PRESERVATION.mjs';
17
+ import './CATALOG_OWNERSHIP.mjs';
17
18
  import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
18
19
  import { stateDirectory } from '../../lib/state-path.mjs';
19
20
 
@@ -276,21 +277,22 @@ test('gera gitignore canonico e de clientes; registro gitignore nao espelha recu
276
277
  assertCheckReadonly(root, {}, 0);
277
278
  });
278
279
 
279
- test('repara somente bloco gitignore e force salva o original sem perder regras externas', t => {
280
+ test('preserva bloco gitignore editado e atualiza bloco conhecido sem perder regras externas', t => {
280
281
  const root = fixture(t, true); run(root);
281
282
  const prefix = '# Regras do usuario\r\n/private-cache/\r\n';
282
283
  const suffix = '# Excecao local\r\n!keep.log\r\n';
284
+ const original = read(root, '.codex/.gitignore');
283
285
  const old = `${prefix}${ignoreBegin}\r\n/custom-inside/\r\n${ignoreEnd}\r\n${suffix}`;
284
286
  write(root, '.codex/.gitignore', old);
285
287
  assertCheckReadonly(root); assertCheckReadonly(root, { force: true });
286
- const result = run(root, { force: true });
287
- assert.equal(readFileSync(join(result.backupRoot, '.codex/.gitignore'), 'utf8'), old);
288
- const repaired = readFileSync(join(root, '.codex/.gitignore'), 'utf8');
289
- assert.ok(repaired.startsWith(prefix)); assert.ok(repaired.endsWith(suffix));
290
- assert.ok(!repaired.includes('/custom-inside/')); assert.ok(repaired.includes('/cache/'));
288
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
289
+ assert.equal(read(root, '.codex/.gitignore'), old);
290
+ write(root, '.codex/.gitignore', `${prefix}${original}${suffix}`);
291
291
  write(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Novo padrao\n{{ALLOWLIST}}\n/cache/\n/session/\n');
292
292
  assertCheckReadonly(root);
293
- run(root);
293
+ const result = run(root, { force: true });
294
+ assert.equal(readFileSync(join(result.backupRoot, '.codex/.gitignore'), 'utf8'), `${prefix}${original}${suffix}`);
295
+ assert.ok(read(root, '.codex/.gitignore').startsWith(prefix));
294
296
  assert.ok(read(root, '.codex/.gitignore').includes('/session/'));
295
297
  assert.ok(readFileSync(join(root, '.codex/.gitignore'), 'utf8').endsWith(suffix));
296
298
  assertCheckReadonly(root, {}, 0);
@@ -374,6 +376,9 @@ test('registros são derivados, determinísticos e --check nunca os corrige', t
374
376
  run(root);
375
377
  write(root, '.pbai4devs/skills.json', '{"incorreto":true}\n');
376
378
  assertCheckReadonly(root);
379
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
380
+ assert.equal(read(root, '.pbai4devs/skills.json'), '{"incorreto":true}\n');
381
+ rmSync(join(root, '.pbai4devs/skills.json'));
377
382
  run(root);
378
383
  assert.ok(Array.isArray(JSON.parse(read(root, '.pbai4devs/skills.json')).entries));
379
384
  rmSync(join(root, '.pbai4devs/adapters/antigravity/agents/revisor.md'));
@@ -455,7 +460,7 @@ test('atualiza fontes, adiciona clientes, remove recursos e migra agrupado para
455
460
  assertCheckReadonly(root, {}, 0);
456
461
  });
457
462
 
458
- test('remove linhas legadas e preserva arquivos sem prova de propriedade', t => {
463
+ test('preserva linhas legadas humanas sem prova de propriedade', t => {
459
464
  const root = fixture(t);
460
465
  run(root);
461
466
  write(root, 'docs/produto/prd/demo/PRD.md', '# Produto real\n');
@@ -463,13 +468,26 @@ test('remove linhas legadas e preserva arquivos sem prova de propriedade', t =>
463
468
  write(root, '.claude/manual/DOC.md', pointer('../../manual/guia%20com%20espa%C3%A7o/DOC.md'));
464
469
  write(root, '.github/prompts/missing.prompt.md', pointer());
465
470
  write(root, '.pbai4devs/espelhos.conf', read(root, '.pbai4devs/espelhos.conf') + 'documento|manual\n' + 'nativo|.github/prompts/missing.prompt.md|.pbai4devs/commands/missing/COMMAND.md\n');
471
+ const config = read(root, '.pbai4devs/espelhos.conf');
466
472
  assertCheckReadonly(root);
467
- run(root);
468
- assert.doesNotMatch(read(root, '.pbai4devs/espelhos.conf'), /documento\||missing/);
473
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
474
+ assert.equal(read(root, '.pbai4devs/espelhos.conf'), config);
469
475
  assert.equal(existsSync(join(root, '.claude/manual/DOC.md')), true);
470
476
  assert.equal(existsSync(join(root, '.github/prompts/missing.prompt.md')), true);
471
477
  assert.ok(existsSync(join(root, 'docs/produto/prd/demo/PRD.md')));
472
478
  assert.ok(existsSync(join(root, 'manual/guia com espaço/DOC.md')));
479
+ assertCheckReadonly(root);
480
+ });
481
+
482
+ test('limpa configuração legada comprovadamente gerenciada e registra novo hash', t => {
483
+ const root = fixture(t);
484
+ const path = '.pbai4devs/espelhos.conf';
485
+ write(root, path, read(root, path) + 'documento|manual\n');
486
+ recordOwned(root, [path]);
487
+ run(root);
488
+ assert.doesNotMatch(read(root, path), /documento\|/);
489
+ const derived = JSON.parse(read(root, '.pbai4devs/state/derived.json'));
490
+ assert.equal(derived.config, createHash('sha256').update(readFileSync(join(root, path))).digest('hex'));
473
491
  assertCheckReadonly(root, {}, 0);
474
492
  });
475
493
 
@@ -745,13 +763,15 @@ test('criar editar e remover recursos atualiza todos os tipos e os índices nati
745
763
  assertCheckReadonly(root, {}, 0);
746
764
  });
747
765
 
748
- test('JSON derivado corrompido é detectado e reparado com backup sem editar fontes', t => {
766
+ test('JSON derivado alterado é preservado até remoção deliberada pelo usuário', t => {
749
767
  const root = completeCatalogFixture(t);
750
768
  run(root);
751
769
  for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) write(root, path, '{corrompido');
752
770
  assertCheckReadonly(root, { force: true });
753
- const result = run(root, { force: true });
754
- for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) assert.equal(read(result.backupRoot, path), '{corrompido');
771
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
772
+ for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) assert.equal(read(root, path), '{corrompido');
773
+ for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) rmSync(join(root, path));
774
+ run(root);
755
775
  assert.equal(JSON.parse(read(root, '.pbai4devs/catalog.json')).schemaVersion, 1);
756
776
  assertCheckReadonly(root, {}, 0);
757
777
  });
@@ -29,21 +29,30 @@ execuções e projetos anteriores não são substituídos nem removidos. Cada
29
29
  exemplo recebe Git local, sem commits nem identidade alterada. O diretório
30
30
  gerado contém um ignore próprio, inclusive em projetos consumidores. Se a
31
31
  preparação falhar, o índice da nova execução permanece em `preparing`.
32
+ O gerador verifica links e identidade da saída antes e depois das etapas, mas
33
+ isso não é uma proteção atômica. Um processo concorrente com permissão para
34
+ trocar diretórios ainda pode redirecionar uma escrita entre as verificações;
35
+ execute-o em uma área sem esse acesso concorrente.
32
36
  O hub `.pbai4devs` e os clientes registrados são copiados, incluindo comandos
33
37
  nativos em TOML. Sem manifesto local, os templates definem os clientes em
34
38
  memória, respeitando a seleção salva. `.vscode` exige registro. Apenas as entradas
35
39
  `AGENTS.md`, `CLAUDE.md` e `GEMINI.md` presentes na origem são incluídas na raiz.
36
40
  Os templates substituem somente entradas com blocos gerenciados válidos;
37
41
  entradas próprias ou editadas são preservadas. Sem estado de instalação,
38
- o mantenedor usa `.pbai4devs/templates/entrypoints/`, com fallback para a raiz.
42
+ as entradas existentes são copiadas sem presumir propriedade do kit.
39
43
  Os testes resolvem cada apontamento pelo formato declarado do cliente e recurso.
40
44
 
41
- As cópias geram os derivados locais antes dos testes, sem alterar a origem.
45
+ As cópias guardam os catálogos originais em `.agent-tests/testar-maturidade/original-catalogs/`
46
+ e geram catálogos operacionais novos, sem alterar os arquivos da origem.
47
+ Arquivos da origem substituídos pela fixture ficam em `original-files/` nessa
48
+ mesma pasta. O relatório identifica cada cópia: o cenário não é um teste do
49
+ projeto original no local.
42
50
  Não copiam o código do produto em `bin/`, `src/`, `scripts/` ou `tests/`,
43
51
  nem essas pastas na localização antiga dentro do hub.
44
52
  Executam sincronização/reparo/idempotência, catálogo, verificação de
45
53
  recursos, diagnóstico, inspeção e gates negativos/positivo. Em dois casos,
46
- criação/edição/renomeação/remoção e corrupção dos índices exercitam o ciclo todo.
54
+ criação, edição, renomeação, remoção e preservação de índices alterados
55
+ exercitam o ciclo todo.
47
56
  Aplicações executam testes HTTP reais em
48
57
  portas efêmeras, encerradas ao fim. Legado e monorepo preservam seus arquivos.
49
58
  Ferramentas copiadas ficam sem downloads: o check negativo é esperado.