@pathbit/agentdevkit 0.0.17 → 0.0.18

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.
@@ -4,14 +4,16 @@ description: Atualiza ou confere os apontamentos compartilhados dos agentes.
4
4
 
5
5
  # Sincronizar
6
6
 
7
- Entrada: sem argumentos usa `--force` para reparar; `--check` só confere.
7
+ Entrada: este comando agêntico usa o script local com `--force` para reparar;
8
+ `--check` só confere. O `sync` da CLI usa as fontes locais e não implica
9
+ `--force`; `update` usa as fontes da versão do pacote executado.
8
10
  Pré-requisitos: Node.js 24+ e manifesto `.pbai4devs/espelhos.conf` válido.
9
11
 
10
12
  Siga [sincronizar-agentes](../../skills/projeto/sincronizar-agentes/SKILL.md)
11
13
  e execute o script correspondente pelo seletor. A reconstrução repara
12
- apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada,
13
- com backup local. Recursos, configurações e links humanos são preservados,
14
- inclusive quando seus nomes coincidem com os destinos do kit.
14
+ apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada.
15
+ Recursos adicionais, configurações, pastas e links humanos são preservados,
16
+ inclusive em colisões de nomes. O backup local não substitui o histórico Git.
15
17
  Após reparar, execute também `--check`.
16
18
 
17
19
  Respeite os clientes registrados no manifesto. Para mudar a seleção salva,
@@ -98,10 +98,33 @@ function localBlock(current, first, last, body) {
98
98
  return text.slice(0, start) + block + text.slice(finish);
99
99
  }
100
100
 
101
- function gitCommand(root, args, input) {
101
+ function gitCommand(root, args, input, encoding = 'utf8') {
102
102
  const env = { ...process.env };
103
103
  for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[key];
104
- return spawnSync('git', [...(['ls-files', 'rm'].includes(args[0]) ? ['--literal-pathspecs'] : []), '-C', root, ...args], { input, encoding: 'utf8', env, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
104
+ return spawnSync('git', [...(['ls-files', 'rm'].includes(args[0]) ? ['--literal-pathspecs'] : []), '-C', root, ...args], { input, encoding, env, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
105
+ }
106
+
107
+ // Compare exact bytes first. Only accept Git's plain text CRLF conversion when
108
+ // effective attributes prove it applies and no custom transformation can run.
109
+ // This deliberately does not invoke hash-object or repository-defined filters.
110
+ export function indexContentMatches(root, path, working, indexed) {
111
+ if (working.equals(indexed)) return true;
112
+ const attributes = gitCommand(root, ['check-attr', '-z', 'text', 'eol', 'filter', 'working-tree-encoding', 'ident', '--', path]);
113
+ if (attributes.status !== 0) return false;
114
+ const parts = attributes.stdout.split('\0');
115
+ const values = {};
116
+ for (let i = 0; i + 2 < parts.length; i += 3) values[parts[i + 1]] = parts[i + 2];
117
+ const absent = value => value === 'unspecified' || value === 'unset';
118
+ if (!absent(values.filter) || !absent(values['working-tree-encoding']) || !absent(values.ident) || values.text === 'unset') return false;
119
+ const config = gitCommand(root, ['config', '--bool', 'core.autocrlf']);
120
+ if (config.status !== 0 && config.status !== 1) return false;
121
+ const textEnabled = values.text === 'set' || values.text === 'auto' || ['lf', 'crlf'].includes(values.eol) || config.status === 0 && config.stdout.trim() === 'true';
122
+ if (!textEnabled || !working.includes(13) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(working.toString('latin1'))) return false;
123
+ try {
124
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(working);
125
+ if (text.replaceAll('\r\n', '').includes('\r')) return false;
126
+ return Buffer.from(text.replaceAll('\r\n', '\n')).equals(indexed);
127
+ } catch { return false; }
105
128
  }
106
129
 
107
130
  function checkedOutput(path) {
@@ -502,25 +525,39 @@ export function planUntrack(root, expectedHashes = {}) {
502
525
  if (!candidates.length || !localStat(root)) return { root, paths: [], hashes: {}, index: '', conflicts: [] };
503
526
  const result = gitCommand(root, ['ls-files', '--stage', '-z']);
504
527
  if (result.status !== 0) {
505
- if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || '')) return { root, paths: [], hashes: {}, index: '', conflicts: [] };
528
+ if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || '')) {
529
+ // Fail closed if a repository exists even when Git cannot be launched.
530
+ let directory = root;
531
+ while (true) {
532
+ if (localStat(join(directory, '.git'))) throw new Error('Nao foi possivel consultar o Git deste projeto; indice e arquivos preservados.');
533
+ const parent = dirname(directory);
534
+ if (parent === directory) break;
535
+ directory = parent;
536
+ }
537
+ return { root, paths: [], hashes: {}, index: '', conflicts: [] };
538
+ }
506
539
  throw new Error(result.error?.message || result.stderr || 'Falha ao ler indice Git.');
507
540
  }
508
541
  const conflicts = [];
509
542
  const records = result.stdout.split('\0').filter(Boolean);
510
- const wanted = new Set(candidates);
543
+ const folded = new Map();
544
+ for (const path of candidates) folded.set(path.toLowerCase(), [...(folded.get(path.toLowerCase()) || []), path]);
511
545
  const matches = new Map();
512
546
  for (const record of records) {
513
547
  const position = record.indexOf('\t'), path = record.slice(position + 1);
514
- if (position < 0 || !wanted.has(path)) continue;
515
- matches.set(path, [...(matches.get(path) || []), record]);
548
+ if (position < 0) continue;
549
+ for (const candidate of folded.get(path.toLowerCase()) || []) {
550
+ matches.set(candidate, [...(matches.get(candidate) || []), record]);
551
+ }
516
552
  }
517
553
  const paths = [...matches.keys()].filter(path => {
518
554
  const file = checkedLocalPath(join(root, path));
519
555
  const entries = matches.get(path);
520
556
  const match = entries.length === 1 && entries[0].match(/^\d+ ([a-f0-9]+) 0\t/);
521
- const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]]) : null;
522
- const owned = localStat(file) && createHash('sha256').update(readFileSync(file)).digest('hex') === expectedHashes[path];
523
- const indexOwned = blob?.status === 0 && createHash('sha256').update(blob.stdout).digest('hex') === expectedHashes[path];
557
+ const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]], undefined, null) : null;
558
+ const data = localStat(file) ? readFileSync(file) : null;
559
+ const owned = data && createHash('sha256').update(data).digest('hex') === expectedHashes[path];
560
+ const indexOwned = entries[0]?.slice(entries[0].indexOf('\t') + 1) === path && blob?.status === 0 && data && indexContentMatches(root, path, data, blob.stdout);
524
561
  if (!owned || !indexOwned) conflicts.push(path);
525
562
  return owned && indexOwned;
526
563
  });
@@ -12,6 +12,9 @@ node --test .pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs
12
12
 
13
13
  `--check` só confere; a execução normal atualiza os recursos. `--force` salva
14
14
  conflitos em `.agent-tests/` antes de reparar os caminhos gerenciados.
15
+ O [comando agêntico](../../commands/sincronizar/COMMAND.md) usa `--force` por
16
+ padrão; executar este script diretamente não ativa essa opção. A CLI oferece
17
+ `sync --dry-run --json` para consultar o plano antes de aplicá-lo.
15
18
  Recursos nativos humanos recebem um bloco curto com a referência ao hub.
16
19
  Texto, frontmatter e nomes existentes são preservados. O estado guarda só o
17
20
  hash desse bloco; edições externas continuam permitidas. Um bloco alterado
@@ -44,3 +47,7 @@ presença de um bloco gerenciado.
44
47
  Entradas novas usam os templates; hashes em `state/derived.json` permitem
45
48
  atualizar arquivos gerados e blocos separadamente, preservando alterações humanas.
46
49
  `--check` também acusa derivados ainda rastreados; não altera o índice.
50
+ Apontamentos obsoletos podem ser removidos quando a propriedade é comprovada;
51
+ arquivos e pastas próprios, inclusive os adicionais dentro do mesmo recurso,
52
+ devem permanecer. Na CLI, confira `untrackedPaths` antes de aplicar: saídas
53
+ geradas comprovadas podem deixar de ser rastreadas, sem excluir seus bytes.
@@ -3,6 +3,7 @@
3
3
  import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, posix, resolve } from 'node:path';
5
5
  import { randomUUID, createHash } from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
6
7
  import { requireNode, projectRoot, isMain } from '../lib/runtime.mjs';
7
8
  import { buildCatalog } from '../lib/catalog.mjs';
8
9
  import { resourcePath } from '../lib/resource-path.mjs';
@@ -10,7 +11,7 @@ import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
10
11
  import { nativePathBoundary } from '../lib/native-paths.mjs';
11
12
  import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
12
13
  import { mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
13
- import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
14
+ import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, planUntrack, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
14
15
  import { stateDirectory } from '../lib/state-path.mjs';
15
16
 
16
17
  const marker = '<!-- gerado: sincronizar-agentes -->';
@@ -79,6 +80,28 @@ export function synchronize({ root = projectRoot, check = false, force = false,
79
80
  requireNode();
80
81
  root = realpathSync(resolve(root));
81
82
  const absolute = value => join(root, ...value.split('/'));
83
+ function assertGitReadableForChanges() {
84
+ // A project may be nested in a worktree; .git can be a file or directory.
85
+ // If that marker exists we must not treat an unavailable Git executable,
86
+ // an invalid repository or an unreadable index as a Git-free project.
87
+ let folder = root, found = false;
88
+ while (true) {
89
+ if (stat(join(folder, '.git'))) { found = true; break; }
90
+ const parent = dirname(folder);
91
+ if (parent === folder) break;
92
+ folder = parent;
93
+ }
94
+ if (!found) return false;
95
+ const env = { ...process.env };
96
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[key];
97
+ const probe = spawnSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
98
+ encoding: 'utf8', env, windowsHide: true, timeout: 20000,
99
+ });
100
+ if (probe.error || probe.status !== 0 || probe.stdout.trim() !== 'true') {
101
+ fail('Nao foi possivel consultar o Git deste projeto; sincronizacao bloqueada para preservar arquivos e indice.');
102
+ }
103
+ return true;
104
+ }
82
105
  function safe(value) {
83
106
  if (typeof value !== 'string' || !value || /^[A-Za-z]:/.test(value) || /[|\\\x00-\x1f\x7f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail(`Caminho invalido: ${value}`);
84
107
  let current = root;
@@ -130,7 +153,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
130
153
  const recorded = recordedHash === digest;
131
154
  return isReference(text) && recorded;
132
155
  };
133
- const onlyReferences = value => { const files = scan(value); return files.length > 0 && ![...preserved.keys()].some(path => path === value || path.startsWith(`${value}/`)) && files.every(file => /\.(md|mdc|toml)$/i.test(file) && referenceFile(file)); };
134
156
  const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
135
157
  const agents = new Map();
136
158
  const formats = new Map();
@@ -295,6 +317,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
295
317
  const remove = new Set();
296
318
  const backup = new Set();
297
319
  const conflicts = new Set();
320
+ const protectedConflicts = new Set();
298
321
  const expected = new Map();
299
322
  const managedBlocks = {};
300
323
  const generatedFiles = {};
@@ -307,17 +330,31 @@ export function synchronize({ root = projectRoot, check = false, force = false,
307
330
  function planRemove(value, custom = false) {
308
331
  safe(value);
309
332
  if (!managed(value) || protectedPath(value)) fail(`Limpeza fora dos espelhos ou caminho protegido: ${value}`);
333
+ // Directories, unowned files and edited references can contain project
334
+ // data even when --force is used. Only a recorded, unchanged reference
335
+ // may be removed; empty directories are never removed recursively.
336
+ if (!stat(safe(value))?.isFile() || !referenceFile(value)) {
337
+ remember(value, 'existing-file');
338
+ if (custom) { conflicts.add(value); protectedConflicts.add(value); }
339
+ return;
340
+ }
310
341
  remove.add(value);
311
342
  if (custom) backup.add(value);
312
343
  }
313
344
  function cleanScope(value) {
314
345
  if (preserveBoundary(value)) return;
315
- if (!stat(safe(value))) return;
316
- if (stat(safe(value)).isDirectory() && !scan(value).length) return;
317
- if (Object.keys(priorBlocks).some(path => path === value || path.startsWith(value + '/'))) return;
318
- if (onlyReferences(value)) planRemove(value);
319
- else if (claimed(value)) remember(value, 'edited-generated-file');
320
- else remember(value);
346
+ const info = stat(safe(value));
347
+ if (!info) return;
348
+ const files = info.isDirectory() ? scan(value) : [value];
349
+ if (info.isDirectory() && !files.length) { remember(value, 'existing-directory'); return; }
350
+ let hasHumanContent = false;
351
+ for (const file of files) {
352
+ if (expected.has(file) || priorBlocks[file] || protectedPath(file)) continue;
353
+ if (referenceFile(file)) planRemove(file);
354
+ else if (claimed(file)) { remember(file, 'edited-generated-file'); hasHumanContent = true; }
355
+ else { remember(file); hasHumanContent = true; }
356
+ }
357
+ if (hasHumanContent && info.isDirectory()) remember(value);
321
358
  }
322
359
  const registries = new Map();
323
360
  const discoveryOutputs = {};
@@ -551,6 +588,22 @@ export function synchronize({ root = projectRoot, check = false, force = false,
551
588
  const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
552
589
  const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
553
590
  const configChanged = !configExists || configText !== finalConfig;
591
+ // Every planned write/removal may cover a different staged version. Compare
592
+ // the worktree bytes with the index before touching any path, even in force
593
+ // mode, and repeat immediately before applying the transaction.
594
+ const affected = sorted([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
595
+ const emptyHash = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
596
+ const affectedHashes = Object.fromEntries(affected.flatMap(value => {
597
+ const info = stat(safe(value));
598
+ if (info && !info.isFile()) return []; // A type conflict already blocks application.
599
+ return [[value, info ? createHash('sha256').update(readFileSync(absolute(value))).digest('hex') : emptyHash]];
600
+ }));
601
+ const hadGit = Object.keys(affectedHashes).length ? assertGitReadableForChanges() : false;
602
+ const affectedIndex = planUntrack(root, affectedHashes);
603
+ for (const path of affectedIndex.conflicts) {
604
+ conflicts.add(path);
605
+ remember(path, 'staged-content-preserved');
606
+ }
554
607
  function capture(value) {
555
608
  const file = safe(value), info = stat(file);
556
609
  if (!info) return null;
@@ -576,15 +629,15 @@ export function synchronize({ root = projectRoot, check = false, force = false,
576
629
  let parent = posix.dirname(value);
577
630
  while (parent !== '.') { if (stat(safe(parent))?.isDirectory()) originalDirectories.add(parent); parent = posix.dirname(parent); }
578
631
  }
579
- const touched = new Set(), createdDirectories = new Set(), restoredDirectories = new Set();
580
- const prunedDirectories = new Map();
581
- let restoring = false;
632
+ const touched = new Set(), createdDirectories = new Set();
582
633
  const summary = { changed: changed.length + rootEntryChanges.length, stale: removals.length, conflicts: conflicts.size, missing: missing.length, legacy: legacy.length, registries: registries.size, gitignores: ignoreFiles.size, manifests: configExists ? 0 : 1, localIgnores: localStatus.changed + localStatus.missing.length + localStatus.trackedOutputs.length + (localStatus.status === 'no-git' ? 1 : 0) };
583
634
  const dirty = Object.values(summary).some(Boolean);
584
635
  output(`${summary.changed} desatualizadas; ${summary.stale} obsoletas; ${summary.conflicts} conflitos; ${summary.missing} origens nativas ausentes; ${summary.legacy} documentos antigos; ${summary.registries} indices derivados; ${summary.gitignores} gitignores; ${summary.localIgnores} exclusoes locais.`);
585
636
  if (localStatus.status === 'no-git') output('Git ausente: apos git init, execute a sincronizacao para proteger os arquivos locais.');
586
637
  const preservedPaths = sorted(preserved.keys()).map(path => preserved.get(path));
587
638
  if (check) return { ...summary, ownedOutputs, managedBlocks, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
639
+ if (affectedIndex.conflicts.length) fail(`Indice Git contem alteracoes proprias; arquivos preservados: ${sorted(affectedIndex.conflicts).join(', ')}`);
640
+ if (protectedConflicts.size) fail(`Conteudo proprio preservado; resolva a colisao antes de sincronizar: ${sorted(protectedConflicts).join(', ')}`);
588
641
  if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
589
642
  if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
590
643
  if (localPlan) assertLocalIgnoreUnchanged(localPlan);
@@ -594,7 +647,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
594
647
  while (parent !== '.' && !stat(safe(parent))) { missing.push(parent); parent = posix.dirname(parent); }
595
648
  for (const directory of missing.reverse()) {
596
649
  mkdirSync(safe(directory)); createdDirectories.add(directory);
597
- if (restoring) restoredDirectories.add(directory);
598
650
  }
599
651
  }
600
652
  function mutate(value, action, text) {
@@ -649,40 +701,27 @@ export function synchronize({ root = projectRoot, check = false, force = false,
649
701
  }
650
702
  try {
651
703
  if (localPlan) assertLocalIgnoreUnchanged(localPlan);
704
+ if (Object.keys(affectedHashes).length) {
705
+ if (!assertGitReadableForChanges() && hadGit) fail('Git deixou de estar disponivel antes da sincronizacao; arquivos preservados.');
706
+ const current = planUntrack(root, affectedHashes);
707
+ if (current.conflicts.length || current.index !== affectedIndex.index) fail('Indice Git mudou antes da sincronizacao; conteudo preservado.');
708
+ }
652
709
  for (const value of removals) mutate(value, () => rmSync(safe(value), { recursive: true, force: true }), null);
653
710
  for (const value of changed) mutate(value, () => atomic(value, expected.get(value)), expected.get(value));
654
711
  for (const [value, text] of registries) mutate(value, () => atomic(value, text), text);
655
712
  for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
656
713
  for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
657
714
  if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
658
- // Só os ancestrais de remoções conhecidas são candidatos à limpeza.
659
- for (const value of removals) {
660
- let parent = posix.dirname(value);
661
- const boundary = value.split('/').slice(0, 2).join('/');
662
- while (parent.startsWith(boundary + '/') || parent === boundary) {
663
- const info = stat(safe(parent));
664
- if (!info?.isDirectory() || readdirSync(absolute(parent)).length) break;
665
- rmdirSync(safe(parent)); prunedDirectories.set(parent, info.mode & 0o777); parent = posix.dirname(parent);
666
- }
667
- }
715
+ // Directory ownership is not recorded: keep even empty parent directories.
668
716
  if (localPlan) applyLocalIgnore(localPlan);
669
717
  } catch (error) {
670
718
  const failures = [];
671
- restoring = true;
672
719
  for (const value of [...touched].reverse()) {
673
720
  try {
674
721
  if (fingerprint(capture(value)) !== currentHashes.get(value)) fail(`Mudanca concorrente preservada: ${value}`);
675
722
  restore(value, originals.get(value));
676
723
  } catch (failure) { failures.push(failure); }
677
724
  }
678
- for (const [directory, mode] of [...prunedDirectories].sort(([left], [right]) => right.length - left.length)) {
679
- try {
680
- if (!stat(safe(directory))) ensureDirectory(`${directory}/.restore-probe`);
681
- const info = stat(safe(directory));
682
- if (!info?.isDirectory() || !restoredDirectories.has(directory) && (info.mode & 0o777) !== mode) fail(`Diretorio concorrente preservado: ${directory}`);
683
- chmodSync(safe(directory), mode);
684
- } catch (failure) { failures.push(failure); }
685
- }
686
725
  for (const directory of sorted(createdDirectories).sort((a, b) => b.length - a.length)) {
687
726
  try { if (!originalDirectories.has(directory) && stat(safe(directory))?.isDirectory() && !readdirSync(absolute(directory)).length) rmdirSync(safe(directory)); }
688
727
  catch (failure) { failures.push(failure); }
@@ -0,0 +1,306 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { tmpdir } from 'node:os';
8
+ import { synchronize } from '../SCRIPT.mjs';
9
+ import { indexContentMatches } from '../../lib/gitignore.mjs';
10
+
11
+ const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
12
+ const original = '.pbai4devs/skills/projeto/criar-skill/SKILL.md';
13
+ const pointer = '.claude/skills/criar-skill/SKILL.md';
14
+ const file = (root, relative) => join(root, relative);
15
+ const put = (root, relative, content) => { mkdirSync(dirname(file(root, relative)), { recursive: true }); writeFileSync(file(root, relative), content); };
16
+ function git(root, ...args) {
17
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
18
+ assert.equal(result.status, 0, `${args.join(' ')}: ${result.stderr}`);
19
+ return result.stdout;
20
+ }
21
+ function fixture(t, withGit = true) {
22
+ mkdirSync(join(repository, '.agent-tests'), { recursive: true });
23
+ const root = mkdtempSync(join(withGit ? join(repository, '.agent-tests') : tmpdir(), 'sync-preservation-'));
24
+ t.after(() => rmSync(root, { recursive: true, force: true }));
25
+ if (withGit) git(root, 'init', '--quiet');
26
+ populate(root);
27
+ return root;
28
+ }
29
+ function populate(root) {
30
+ put(root, '.gitignore', '# Human ignore\nnode_modules/\n');
31
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache\n/*\n!/.gitignore\n{{ALLOWLIST}}\n/cache/\n*.log\n');
32
+ put(root, '.pbai4devs/templates/gitignore/clients.json', '{}\n');
33
+ put(root, '.pbai4devs/templates/gitignore/shared.ignore', '# Shared\n/tools/*/.local/\n');
34
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\n');
35
+ put(root, original, '---\nname: criar-skill\ndescription: Criar recurso.\n---\n\n# Criar skill\n');
36
+ assert.equal(run(root).exitCode, 0);
37
+ assert.ok(existsSync(file(root, pointer)));
38
+ }
39
+ function run(root, options = {}) {
40
+ return synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {}, ...options });
41
+ }
42
+ function withoutGit(t, callback) {
43
+ const emptyPath = mkdtempSync(join(tmpdir(), 'sync-empty-path-'));
44
+ t.after(() => rmSync(emptyPath, { recursive: true, force: true }));
45
+ const keys = Object.keys(process.env).filter(key => key.toLowerCase() === 'path');
46
+ const previous = new Map(keys.map(key => [key, process.env[key]]));
47
+ if (!keys.length) keys.push('PATH');
48
+ try {
49
+ for (const key of keys) process.env[key] = emptyPath;
50
+ return callback();
51
+ } finally {
52
+ for (const key of keys) {
53
+ if (previous.has(key)) process.env[key] = previous.get(key);
54
+ else delete process.env[key];
55
+ }
56
+ }
57
+ }
58
+
59
+ for (const [withGit, force] of [[true, false], [true, true], [false, false], [false, true]]) {
60
+ test(`remoção obsoleta preserva pasta vazia humana (git=${withGit}, force=${force})`, t => {
61
+ const root = fixture(t, withGit), human = '.claude/skills/criar-skill/Equipe-Vazia';
62
+ mkdirSync(file(root, human));
63
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
64
+ const index = withGit ? git(root, 'ls-files', '--stage', '-z') : null;
65
+ const ignore = readFileSync(file(root, '.gitignore'));
66
+ rmSync(dirname(file(root, original)), { recursive: true });
67
+ const check = run(root, { check: true, force });
68
+ assert.notEqual(check.exitCode, 0);
69
+ assert.ok(existsSync(file(root, pointer)));
70
+ assert.ok(existsSync(file(root, human)));
71
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
72
+ run(root, { force });
73
+ assert.equal(existsSync(file(root, pointer)), false, 'apontamento obsoleto comprovado é removido');
74
+ assert.ok(existsSync(file(root, human)), 'pasta vazia própria permanece');
75
+ assert.deepEqual(readFileSync(file(root, '.gitignore')), ignore);
76
+ if (withGit) assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
77
+ });
78
+ }
79
+
80
+ for (const force of [false, true]) {
81
+ test(`índice com conteúdo humano divergente bloqueia remoção (force=${force})`, t => {
82
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
83
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nConteúdo humano staged.\n')]));
84
+ git(root, 'add', '-f', '--', pointer);
85
+ const index = git(root, 'ls-files', '--stage', '-z');
86
+ put(root, pointer, generated);
87
+ rmSync(dirname(file(root, original)), { recursive: true });
88
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
89
+ const check = run(root, { check: true, force });
90
+ assert.notEqual(check.exitCode, 0);
91
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
92
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
93
+ assert.throws(() => run(root, { force }), /conflitos|indice|índice/i);
94
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
95
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
96
+ });
97
+ }
98
+
99
+ for (const force of [false, true]) {
100
+ test(`índice divergente impede atualização de apontamento ativo (force=${force})`, t => {
101
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
102
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
103
+ git(root, 'add', '-f', '--', pointer);
104
+ const index = git(root, 'ls-files', '--stage', '-z');
105
+ put(root, pointer, generated);
106
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
107
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
108
+ assert.notEqual(run(root, { check: true, force }).exitCode, 0);
109
+ assert.throws(() => run(root, { force }), /indice/i);
110
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
111
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
112
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
113
+ });
114
+ }
115
+
116
+ for (const flag of ['--assume-unchanged', '--skip-worktree']) {
117
+ test(`índice divergente continua protegido com ${flag}`, t => {
118
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
119
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
120
+ git(root, 'add', '-f', '--', pointer);
121
+ put(root, pointer, generated);
122
+ git(root, 'update-index', flag, '--', pointer);
123
+ const index = git(root, 'ls-files', '--stage', '-z');
124
+ const flags = git(root, 'ls-files', '-v', '--', pointer);
125
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
126
+ assert.notEqual(run(root, { check: true, force: true }).exitCode, 0);
127
+ assert.throws(() => run(root, { force: true }), /indice/i);
128
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
129
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
130
+ assert.equal(git(root, 'ls-files', '-v', '--', pointer), flags);
131
+ });
132
+ }
133
+
134
+ test('índice com alias de caixa bloqueia alteração conservadoramente em qualquer sistema', t => {
135
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
136
+ const alias = pointer.replace('/skills/', '/SKILLS/');
137
+ put(root, alias, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
138
+ git(root, 'add', '-f', '--', alias);
139
+ const index = git(root, 'ls-files', '--stage', '-z');
140
+ put(root, pointer, generated);
141
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
142
+ const aliasBytes = readFileSync(file(root, alias));
143
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
144
+ const ignore = readFileSync(file(root, '.gitignore'));
145
+ const preserved = () => {
146
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
147
+ assert.deepEqual(readFileSync(file(root, alias)), aliasBytes);
148
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
149
+ assert.deepEqual(readFileSync(file(root, '.gitignore')), ignore);
150
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
151
+ };
152
+ let check, error;
153
+ try { check = run(root, { check: true, force: true }); } catch (failure) { error = failure; }
154
+ if (error) assert.match(error.message, /maiusculas\/minusculas/i);
155
+ else assert.notEqual(check.exitCode, 0);
156
+ preserved();
157
+ assert.throws(() => run(root, { force: true }), /indice|maiusculas\/minusculas/i);
158
+ preserved();
159
+ });
160
+
161
+ test('comparação de índice entende CRLF autorizado e recusa filtros, binários e divergência', t => {
162
+ const root = fixture(t), path = 'arquivo.txt';
163
+ const crlf = Buffer.from('texto\r\noutra linha\r\n'), lf = Buffer.from('texto\noutra linha\n');
164
+ git(root, 'config', 'core.autocrlf', 'false');
165
+ git(root, 'config', 'core.attributesfile', file(root, 'atributos-ausentes'));
166
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
167
+ git(root, 'config', 'core.autocrlf', 'true');
168
+ assert.equal(indexContentMatches(root, path, crlf, lf), true);
169
+ assert.equal(indexContentMatches(root, path, crlf, Buffer.from('texto humano\noutra linha\n')), false);
170
+ assert.equal(indexContentMatches(root, path, Buffer.from([0, 13, 10]), Buffer.from([0, 10])), false);
171
+ assert.equal(indexContentMatches(root, path, Buffer.from('um\r\ndois\r'), Buffer.from('um\ndois\r')), false);
172
+ assert.equal(indexContentMatches(root, path, Buffer.from('\ufefftexto\r\n'), Buffer.from('texto\n')), false);
173
+ assert.equal(indexContentMatches(root, path, Buffer.from('\ufefftexto\r\n'), Buffer.from('\ufefftexto\n')), true);
174
+ put(root, '.gitattributes', '*.txt -text\n');
175
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
176
+ put(root, '.gitattributes', '*.txt text filter=nao-executar\n');
177
+ git(root, 'config', 'filter.nao-executar.clean', 'exit 1');
178
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
179
+ put(root, '.gitattributes', '*.txt text working-tree-encoding=UTF-16\n');
180
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
181
+ put(root, '.gitattributes', '*.txt text ident\n');
182
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
183
+ put(root, '.gitattributes', '*.txt text eol=crlf\n');
184
+ git(root, 'config', 'core.autocrlf', 'false');
185
+ assert.equal(indexContentMatches(root, path, crlf, lf), true);
186
+ });
187
+
188
+ test('sync atualiza apontamento staged equivalente em repositório com autocrlf', t => {
189
+ const root = fixture(t), generated = readFileSync(file(root, pointer), 'utf8').replaceAll('\n', '\r\n');
190
+ git(root, 'config', 'core.autocrlf', 'true');
191
+ put(root, pointer, generated);
192
+ git(root, 'add', '-f', '--', pointer);
193
+ const index = git(root, 'ls-files', '--stage', '-z');
194
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
195
+ assert.equal(run(root, { force: true }).exitCode, 0);
196
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
197
+ });
198
+
199
+ for (const target of ['.pbai4devs/state/derived.json', '.claude/.gitignore']) {
200
+ test(`índice divergente impede atualização de ${target}`, t => {
201
+ const root = fixture(t), generated = readFileSync(file(root, target));
202
+ put(root, target, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
203
+ git(root, 'add', '-f', '--', target);
204
+ const index = git(root, 'ls-files', '--stage', '-z');
205
+ put(root, target, generated);
206
+ if (target.endsWith('derived.json')) {
207
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
208
+ } else {
209
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache atualizado\n/*\n!/.gitignore\n{{ALLOWLIST}}\n/cache/\n*.log\n');
210
+ }
211
+ assert.notEqual(run(root, { check: true, force: true }).exitCode, 0);
212
+ assert.throws(() => run(root, { force: true }), /indice/i);
213
+ assert.deepEqual(readFileSync(file(root, target)), generated);
214
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
215
+ });
216
+ }
217
+
218
+ for (const options of [{ check: true }, { force: true }]) {
219
+ test(`Git indisponível bloqueia limpeza em repositório (check=${Boolean(options.check)}, force=${Boolean(options.force)})`, t => {
220
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
221
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nConteúdo humano staged.\n')]));
222
+ git(root, 'add', '-f', '--', pointer);
223
+ const index = git(root, 'ls-files', '--stage', '-z');
224
+ put(root, pointer, generated);
225
+ rmSync(dirname(file(root, original)), { recursive: true });
226
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
227
+ withoutGit(t, () => assert.throws(() => run(root, options), /consultar o Git.*preservar/i));
228
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
229
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
230
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
231
+ });
232
+ }
233
+
234
+ test('projeto realmente sem Git pode limpar apontamento próprio sem executável Git', t => {
235
+ const root = fixture(t, false), human = '.claude/skills/criar-skill/equipe-vazia';
236
+ mkdirSync(file(root, human));
237
+ rmSync(dirname(file(root, original)), { recursive: true });
238
+ withoutGit(t, () => assert.equal(run(root, { force: true }).exitCode, 0));
239
+ assert.equal(existsSync(file(root, pointer)), false);
240
+ assert.ok(existsSync(file(root, human)));
241
+ });
242
+
243
+ test('preserva diretório preexistente sem prova de propriedade após remover único apontamento', t => {
244
+ const root = fixture(t), directory = dirname(file(root, pointer));
245
+ rmSync(dirname(file(root, original)), { recursive: true });
246
+ assert.equal(run(root, { force: true }).exitCode, 0);
247
+ assert.equal(existsSync(file(root, pointer)), false);
248
+ assert.ok(existsSync(directory));
249
+ });
250
+
251
+ test('alteração concorrente no índice bloqueia limpeza antes de qualquer exclusão', t => {
252
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
253
+ rmSync(dirname(file(root, original)), { recursive: true });
254
+ let staged;
255
+ assert.throws(() => synchronize({ root, localIgnores: false, generateEntrypoints: false, force: true,
256
+ output() {
257
+ if (staged !== undefined) return;
258
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nStaged pela equipe.\n')]));
259
+ git(root, 'add', '-f', '--', pointer);
260
+ staged = git(root, 'ls-files', '--stage', '-z');
261
+ put(root, pointer, generated);
262
+ },
263
+ }), /indice git mudou/i);
264
+ assert.notEqual(staged, undefined);
265
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
266
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), staged);
267
+ });
268
+
269
+ test('índice divergente é protegido quando o projeto fica em subpasta do repositório Git', t => {
270
+ const outer = mkdtempSync(join(tmpdir(), 'sync-parent-git-'));
271
+ t.after(() => rmSync(outer, { recursive: true, force: true }));
272
+ git(outer, 'init', '--quiet');
273
+ const root = join(outer, 'projeto'); mkdirSync(root);
274
+ populate(root);
275
+ const generated = readFileSync(file(root, pointer));
276
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nStaged pela equipe.\n')]));
277
+ git(root, 'add', '-f', '--', pointer);
278
+ const index = git(outer, 'ls-files', '--stage', '-z');
279
+ put(root, pointer, generated);
280
+ rmSync(dirname(file(root, original)), { recursive: true });
281
+ assert.throws(() => run(root, { force: true }), /indice/i);
282
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
283
+ assert.equal(git(outer, 'ls-files', '--stage', '-z'), index);
284
+ });
285
+
286
+ test('conteúdo humano na pasta e apontamento gerenciado editado sobrevivem à limpeza forçada', t => {
287
+ const root = fixture(t), human = '.claude/skills/criar-skill/notas-da-equipe.md';
288
+ const notes = '# Referência própria\n', edited = Buffer.concat([readFileSync(file(root, pointer)), Buffer.from('\nNota própria.\n')]);
289
+ put(root, human, notes);
290
+ put(root, pointer, edited);
291
+ rmSync(dirname(file(root, original)), { recursive: true });
292
+ run(root, { force: true });
293
+ assert.equal(readFileSync(file(root, human), 'utf8'), notes);
294
+ assert.deepEqual(readFileSync(file(root, pointer)), edited);
295
+ });
296
+
297
+ test('colisão com arquivo humano ancestral não é removida por --force', t => {
298
+ const root = fixture(t), ancestor = '.claude/skills/equipe-custom';
299
+ const human = 'Arquivo da equipe; não substituir.\n';
300
+ put(root, ancestor, human);
301
+ put(root, '.pbai4devs/skills/projeto/equipe-custom/SKILL.md', '---\nname: equipe-custom\ndescription: Exemplo.\n---\n\n# Equipe\n');
302
+ const index = git(root, 'ls-files', '--stage', '-z');
303
+ try { run(root, { force: true }); } catch (error) { assert.match(error.message, /conflito|gerenciado|arquivo|diretor/i); }
304
+ assert.equal(readFileSync(file(root, ancestor), 'utf8'), human);
305
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
306
+ });
@@ -13,6 +13,7 @@ import './LOCAL_IGNORE.mjs';
13
13
  import './HUMAN_OWNERSHIP.mjs';
14
14
  import './COEXISTENCE.mjs';
15
15
  import './MANAGED_CONTENT.mjs';
16
+ import './PRESERVATION.mjs';
16
17
  import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
17
18
  import { stateDirectory } from '../../lib/state-path.mjs';
18
19
 
@@ -140,7 +141,12 @@ test('sincroniza somente principais, preserva metadados e usa referências UTF-8
140
141
  assert.match(read(root, '.kimi-code/agents/revisor.md'), /tools: \[Read, Grep, Glob\]/);
141
142
  assert.ok(existsSync(join(root, '.claude/skills/projeto/demo/SKILL.md')));
142
143
  for (const client of ['.claude', '.opencode', '.github', '.codex', '.cursor', '.kimi-code', '.vscode', '.agents', '.agent', '.gemini']) {
143
- for (const kind of ['templates', 'scripts', 'tools', 'external-skills', 'state', 'estado', 'tests', 'docs', 'adapters', 'extra', 'README.md', 'INSTRUCTIONS.md']) assert.equal(existsSync(join(root, client, kind)), false, `${client}/${kind}`);
144
+ for (const kind of ['templates', 'scripts', 'tools', 'external-skills', 'state', 'estado', 'tests', 'docs', 'adapters', 'extra', 'README.md', 'INSTRUCTIONS.md']) {
145
+ if (client === '.claude' && ['templates', 'scripts', 'tools', 'external-skills', 'state', 'tests', 'docs', 'adapters'].includes(kind)) {
146
+ assert.equal(existsSync(join(root, client, kind, 'demo/REFERENCE.md')), false);
147
+ assert.ok(existsSync(join(root, client, kind)), 'diretório sem prova de propriedade permanece');
148
+ } else assert.equal(existsSync(join(root, client, kind)), false, `${client}/${kind}`);
149
+ }
144
150
  }
145
151
  assert.deepEqual(JSON.parse(read(root, '.pbai4devs/agents.json')).entries, [{ path: '.pbai4devs/agents/revisor/AGENT.md' }]);
146
152
  assert.ok(JSON.parse(read(root, '.pbai4devs/skills.json')).entries.some(entry => entry.path === '.pbai4devs/skills/grupo com espaço/outro'));
@@ -441,7 +447,8 @@ test('atualiza fontes, adiciona clientes, remove recursos e migra agrupado para
441
447
  rmSync(join(root, '.pbai4devs/rules/example/RULE.md'));
442
448
  write(root, '.pbai4devs/espelhos.conf', read(root, '.pbai4devs/espelhos.conf').replace('agente|.claude\n', 'agente|.claude|plano\n'));
443
449
  run(root);
444
- assert.equal(existsSync(join(root, '.claude/skills/projeto')), false);
450
+ assert.equal(existsSync(join(root, '.claude/skills/projeto/demo/SKILL.md')), false);
451
+ assert.ok(existsSync(join(root, '.claude/skills/projeto')));
445
452
  assert.ok(existsSync(join(root, '.claude/skills/demo/SKILL.md')));
446
453
  assert.equal(existsSync(join(root, '.codex/rules/example.md')), false);
447
454
  assert.deepEqual(JSON.parse(read(root, '.pbai4devs/rules.json')).entries, []);
@@ -882,7 +889,8 @@ test('manifestos Antigravity acompanham skills, regras e agentes agrupados', t =
882
889
  rmSync(join(root, '.pbai4devs/skills/projeto/demo'), { recursive: true });
883
890
  run(root, { generateEntrypoints: true });
884
891
  assert.deepEqual(JSON.parse(read(root, '.agents/skills.json')).entries, [{ path: '.agents/skills/projeto/new-skill' }]);
885
- assert.equal(existsSync(join(root, '.agents/skills/projeto/demo')), false);
892
+ assert.equal(existsSync(join(root, '.agents/skills/projeto/demo/SKILL.md')), false);
893
+ assert.ok(existsSync(join(root, '.agents/skills/projeto/demo')));
886
894
  assertCheckReadonly(root, { generateEntrypoints: true }, 0);
887
895
  });
888
896
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.18 — 2026-11-13
8
+
9
+ - Sincronização preserva recursos próprios, colisões e alterações humanas.
10
+ - Limpeza de recursos obsoletos exige comprovação de propriedade.
11
+ - Comandos e efeitos sobre arquivos, índice e backups documentados.
12
+
7
13
  ## 0.0.17 — 2026-11-13
8
14
 
9
15
  - A pasta compartilhada de estado passa a se chamar `.pbai4devs/state`.
@@ -22,17 +22,17 @@ atendido; lá os comandos de manutenção usam o pacote por `npx`.
22
22
 
23
23
  | Comando | Resultado |
24
24
  | --- | --- |
25
- | `init [pasta]` | Instala ou reaplica os recursos. |
26
- | `update` | Usa os recursos da versão do pacote executado. |
27
- | `sync` | Reconstrói as derivações da fonte do projeto. |
28
- | `sync --check` | Confere as derivações sem escrever. |
29
- | `doctor` | Diagnostica a instalação. |
30
- | `next` | Orienta a próxima etapa do projeto. |
25
+ | `init [pasta]` | Instala o kit no projeto ou reaplica a instalação. |
26
+ | `update` | Atualiza a fonte usando a versão do pacote executado. |
27
+ | `sync` | Atualiza catálogos e apontamentos a partir da fonte local. |
28
+ | `sync --check` | Confere sincronização sem alterar o projeto. |
29
+ | `doctor` | Diagnostica o kit e indica divergências. |
30
+ | `next` | Mostra a próxima etapa, sem iniciá-la. |
31
31
  | `lang [idioma]` | Consulta ou altera o idioma: pt, en ou es. |
32
- | `agents` | Consulta clientes disponíveis e seleção local. |
33
- | `agents codex,claude` | Altera a seleção local. |
34
- | `agents all` | Habilita todos os clientes. |
35
- | `tools <ação>` | Executa o gerenciador de ferramentas. |
32
+ | `agents` | Mostra os clientes disponíveis e a seleção salva. |
33
+ | `agents codex,claude` | Altera a seleção e os apontamentos necessários. |
34
+ | `agents all` | Seleciona todos os clientes compatíveis. |
35
+ | `tools <ação>` | Consulta, instala, atualiza ou executa ferramentas. |
36
36
  | `--help`, `--version` | Ajuda ou versão do pacote. |
37
37
 
38
38
  Sem comando, a CLI executa `init`, que pode criar arquivos. `language` é alias
@@ -48,16 +48,47 @@ de `lang`; `clients`, de `agents`. O idioma não traduz documentos existentes.
48
48
  | `--clients <lista>` | Alias de `--agents`. |
49
49
  | `--yes`, `-y` | Evita perguntas iniciais; não força conflitos. |
50
50
  | `--dry-run` | Exibe o plano sem aplicar alterações. |
51
- | `--force` | Substitui conflitos gerenciados, com backup. |
51
+ | `--force` | Solicita reparos gerenciados permitidos, com backup. |
52
52
  | `--json` | Produz uma resposta JSON para automação. |
53
53
 
54
54
  O primeiro uso interativo reúne idioma e clientes. Sem interação, valem as
55
55
  opções explícitas, a escolha local salva e, por último, `all`. Confira os
56
56
  [IDs e dependências](../../.pbai4devs/COMPATIBILIDADE.md).
57
57
 
58
- `update` não procura automaticamente a próxima versão: execute o pacote
59
- da versão desejada. `sync` não atualiza a fonte distribuída.
60
- A seleção e os derivados são locais; o source compartilhado permanece no Git.
58
+ `update` usa a versão executada; para escolher outra, fixe-a no nome do
59
+ pacote. `sync` usa o que já está em `.pbai4devs`: ele não baixa outra versão.
60
+ Ambos conciliam recursos e removem apontamentos obsoletos quando a propriedade
61
+ do kit é comprovada. Recursos e configurações próprios permanecem no projeto.
62
+ Diretórios antigos sem comprovação de propriedade podem permanecer vazios.
63
+ Ao mudar a seleção, pastas compartilhadas continuam quando outro cliente
64
+ precisa delas; arquivos adicionais da equipe devem ser preservados.
65
+
66
+ Antes de aplicar em projeto existente, confira o plano e depois o diagnóstico:
67
+
68
+ ```console
69
+ npx @pathbit/agentdevkit update --dry-run --json
70
+ npx @pathbit/agentdevkit update
71
+ npx @pathbit/agentdevkit doctor
72
+ npx @pathbit/agentdevkit sync --check
73
+ ```
74
+
75
+ Use `init --dry-run --json`, `sync --dry-run --json` ou
76
+ `agents codex,claude --dry-run --json` para as outras operações. Examine
77
+ `plan`, `conflicts`, `preserved`, `untrackedPaths` e `localIgnore` antes de
78
+ aplicar. Conflitos sem solução interrompem a operação; não presuma que uma
79
+ colisão de nomes torna um arquivo propriedade do kit.
80
+
81
+ O sincronizador independente também pode ser executado pelo
82
+ [script local](../../.pbai4devs/scripts/sincronizar-agentes/README.md). Seu
83
+ `--check` apenas confere e `--force` é explícito na linha de comando. O
84
+ comando agêntico [sincronizar](../../.pbai4devs/commands/sincronizar/COMMAND.md)
85
+ orienta executar o script com `--force`; ele não é o mesmo que `sync` da CLI.
86
+
87
+ No Git, o kit preserva o `.gitignore` da raiz e pode atualizar
88
+ `.git/info/exclude`. Se o plano indicar `untrackedPaths`, a operação pode
89
+ retirar do índice saídas comprovadamente geradas, mantendo seus arquivos no
90
+ disco. Confira a lista: `--dry-run` e `sync --check` não alteram o índice.
91
+ Backups locais ficam em `.agent-tests/` e não substituem o histórico Git.
61
92
 
62
93
  ## Ferramentas
63
94
 
@@ -80,7 +111,7 @@ do separador. `--help` e `--version` do kit não exigem um projeto instalado.
80
111
  As respostas incluem `ok`, `command`, `version`, `language`, `target` e
81
112
  `next`. Conforme a operação, há seleção, plano, conflitos, backup e erros.
82
113
  Leia os campos estruturados, não dependa da tradução da mensagem.
83
- `preserved` lista caminhos nativos humanos mantidos pelo projeto, com o motivo.
114
+ `preserved` lista caminhos mantidos pelo projeto e o motivo.
84
115
  Um complemento pode ser gerenciado por bloco sem tornar o arquivo propriedade
85
116
  do kit. Conteúdo humano não é substituído por `--force`.
86
117
 
@@ -111,7 +111,7 @@ usar `git add`. No GitHub Packages, use o prefixo de registry da instalação.
111
111
 
112
112
  O bloco local também protege `.agent-tests`, `tmp`, `temp`, `.playwright*`
113
113
  e relatórios de testes, inclusive com letras maiúsculas e em subpastas.
114
- O conteúdo do `.gitignore` existente e os arquivos rastreados são preservados.
114
+ O conteúdo do `.gitignore` existente na raiz é preservado.
115
115
  Uma exceção que reabra arquivo privado ou artefato é informada como conflito;
116
116
  o projeto decide como ajustar suas próprias regras.
117
117
  Negativas com curingas também podem ser recusadas preventivamente, mesmo antes
@@ -121,6 +121,9 @@ a validação fica restrita ao projeto e aos ignores de seus ancestrais.
121
121
 
122
122
  Arquivos já rastreados exigem migração do índice: apenas saídas identificadas
123
123
  do kit podem deixar de ser rastreadas, preservando seus bytes no disco.
124
+ O plano informa esses caminhos em `untrackedPaths` antes de aplicar; examine-os
125
+ e preserve alterações preparadas da equipe. `--dry-run` e `sync --check` não
126
+ alteram o índice.
124
127
  Artefatos já rastreados são informados por `doctor` e `sync --check` para
125
128
  revisão; não são removidos automaticamente do índice.
126
129
  Revise o plano e os conflitos em um projeto existente; use `--dry-run` antes
@@ -133,13 +136,20 @@ continua válida; `agents` consulta e `agents codex,claude` altera a seleção.
133
136
  `agents all` habilita todos. As pastas compartilhadas permanecem quando
134
137
  necessárias para outro cliente, como `.agents` para Codex e Antigravity.
135
138
 
136
- Confira a simulação antes de atualizar um projeto existente. Arquivos
137
- gerenciados alterados geram conflito; use `--force` somente após revisar o
138
- backup e o conteúdo que será substituído. Preserve os dados locais, as
139
- configurações próprias e os commits do projeto.
139
+ Confira `update --dry-run --json` antes de atualizar um projeto existente.
140
+ Revise arquivos criados, atualizados e removidos, conflitos, `preserved` e
141
+ eventuais alterações do índice. `sync` usa as fontes já instaladas e não baixa
142
+ uma nova versão. Consulte a [referência da CLI](cli.md) para todos os comandos.
140
143
 
141
- Entradas como `AGENTS.md`, `claude.md` e `GEMINI.md`, além de skills, agentes,
142
- comandos e regras existentes, recebem apenas um complemento do kit:
144
+ Skills e outros recursos próprios podem coexistir com os do kit. Um nome
145
+ igual não comprova propriedade: conteúdo humano e arquivos adicionais não
146
+ devem ser descartados para reconstruir apontamentos. Remoções automáticas
147
+ ficam restritas a arquivos obsoletos cuja propriedade possa ser comprovada.
148
+ `--force` não autoriza substituir conteúdo próprio; backups ficam em
149
+ `.agent-tests/` e devem ser revisados quando houver reparo gerenciado.
150
+
151
+ Em entradas como `AGENTS.md`, `claude.md` e `GEMINI.md`, e em recursos nativos
152
+ compatíveis, o kit pode acrescentar apenas uma referência delimitada:
143
153
 
144
154
  ```markdown
145
155
  <!-- BEGIN AGENTDEVKIT -->
@@ -148,7 +158,9 @@ Leia e siga [origem](caminho/para/.pbai4devs/recurso.md).
148
158
  ```
149
159
 
150
160
  O texto e o frontmatter humanos permanecem; a referência usa o caminho real
151
- do recurso. Repetir a operação não duplica o bloco. Edite suas instruções fora
161
+ do recurso. Um arquivo que não possa receber o bloco é preservado e informado,
162
+ sem prometer a adição do apontamento. Repetir a operação não duplica o bloco.
163
+ Edite suas instruções fora
152
164
  dele: atualizações alteram somente a contribuição do kit. Nomes de entradas
153
165
  com caixa diferente são reaproveitados quando há uma única opção.
154
166
  Comandos Gemini mantêm a referência dentro do valor `prompt` em TOML válido.
@@ -21,6 +21,7 @@ description: Incrementos verificáveis do AgentDevKit.
21
21
  especialistas, colaboração e provas.
22
22
  - [Confiabilidade da pipeline](ci-resilience/SPEC.md): testes e releases.
23
23
  - [Pasta de estado](state-directory/SPEC.md): migração segura para `state`.
24
+ - [Sincronização segura](safe-synchronization/SPEC.md): preservar recursos próprios.
24
25
  - [Integração sem substituição][additive]: acréscimos em arquivos existentes.
25
26
  - [Compatibilidade com projetos reais][oss]: projetos públicos e exemplos reproduzíveis.
26
27
  - [Ignore do projeto](project-ignore/SPEC.md): preservar o arquivo da raiz.
@@ -0,0 +1,21 @@
1
+ ---
2
+ description: Sincronização segura com recursos e alterações próprios do projeto.
3
+ ---
4
+
5
+ # Sincronização segura
6
+
7
+ Projetos podem ter arquivos próprios e recursos adicionais aos do pacote.
8
+ Os comandos devem preservar esse conteúdo ao instalar, atualizar e sincronizar.
9
+
10
+ ## Critérios de aceite
11
+
12
+ - AC-001: Recursos humanos ou modificados no hub e nos clientes são
13
+ preservados, inclusive em colisões e com `--force`.
14
+ - AC-002: A limpeza remove somente saídas obsoletas com propriedade comprovada;
15
+ pastas e arquivos adicionais da equipe não são removidos.
16
+ - AC-003: Um conteúdo divergente preparado no índice não é descartado pela
17
+ sincronização; `.gitignore` da raiz e dados humanos são preservados.
18
+ - AC-004: `init`, `update`, `sync`, consultas e opções estão documentados com
19
+ seus efeitos sobre arquivos, backups, seleção de clientes e Git.
20
+ - AC-005: Testes exercitam instalações existentes, obsolescência, colisões,
21
+ execução repetida, Git e falhas; a CI contempla Linux, Windows e macOS.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathbit/agentdevkit",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
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
@@ -42,6 +42,16 @@ const messages = {
42
42
  };
43
43
  export const text = (language, key) => messages[language || 'pt'][key] || key;
44
44
  export function explainError(language, code) {
45
+ if (code === 'E_STAGED') return {
46
+ pt: 'Há alterações preparadas diferentes dos arquivos locais; revise-as antes de atualizar.',
47
+ en: 'Staged changes differ from the working files; review them before updating.',
48
+ es: 'Hay cambios preparados distintos de los archivos locales; revíselos antes de actualizar.',
49
+ }[language || 'pt'];
50
+ if (code === 'E_GIT') return {
51
+ pt: 'Não foi possível verificar o índice Git com segurança; nenhum arquivo foi alterado.',
52
+ en: 'The Git index could not be checked safely; no files were changed.',
53
+ es: 'No se pudo verificar el índice Git de forma segura; no se modificaron archivos.',
54
+ }[language || 'pt'];
45
55
  if (code === 'E_CONFIG_PARSE') return {
46
56
  pt: 'Não foi possível analisar a configuração legada com segurança; revise o frontmatter e as aspas antes de executar update.',
47
57
  en: 'The legacy configuration could not be safely parsed; review its frontmatter and quoted values before running update.',
package/src/install.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, writeFileSync, readdirSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, rmdirSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
6
  import { synchronize, isReference } from '../.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
6
- import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack } from '../.pbai4devs/scripts/lib/gitignore.mjs';
7
+ import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack, indexContentMatches } from '../.pbai4devs/scripts/lib/gitignore.mjs';
7
8
  import { nativePathBoundary } from '../.pbai4devs/scripts/lib/native-paths.mjs';
8
9
  import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, extractManagedBlock, managedBlockDigest, mergeManagedContent, stripManagedContent } from '../.pbai4devs/scripts/lib/managed-content.mjs';
9
10
  import { extractTomlManagedBlock, tomlManagedBlockDigest, mergeTomlContent, stripTomlContent } from '../.pbai4devs/scripts/lib/managed-toml.mjs';
@@ -15,6 +16,122 @@ const hash = value => createHash('sha256').update(value).digest('hex');
15
16
  const portable = value => value.split(sep).join('/');
16
17
  const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
17
18
  const stat = path => { try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
19
+
20
+ // Protect staged human bytes that differ from the working tree, without
21
+ // treating an unchanged HEAD entry as a staged edit or rewriting the index.
22
+ function stagedDivergences(root) {
23
+ let anchor = resolve(root);
24
+ while (!existsSync(anchor)) {
25
+ const parent = dirname(anchor);
26
+ if (parent === anchor) break;
27
+ anchor = parent;
28
+ }
29
+ const prefix = portable(relative(anchor, root));
30
+ let hasGitMarker = false;
31
+ for (let current = anchor; ; current = dirname(current)) {
32
+ if (stat(join(current, '.git'))) { hasGitMarker = true; break; }
33
+ if (dirname(current) === current) break;
34
+ }
35
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
36
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_EXTERNAL_DIFF']) delete env[key];
37
+ const run = args => spawnSync('git', ['-C', anchor, '-c', 'core.fsmonitor=false', ...args], {
38
+ env, timeout: 30000, windowsHide: true, maxBuffer: 16 * 1024 * 1024,
39
+ });
40
+ const repository = run(['rev-parse', '--is-inside-work-tree']);
41
+ if (repository.error || repository.status !== 0) {
42
+ if (hasGitMarker) fail('E_GIT', 'Unable to inspect the existing Git repository safely.');
43
+ if (repository.error?.code === 'ENOENT' || !repository.error && repository.status !== 0) return new Set();
44
+ }
45
+ if (repository.error || repository.status !== 0 || repository.stdout.toString('utf8').trim() !== 'true') fail('E_GIT', 'Unable to inspect Git worktree.');
46
+ // `git diff` against the worktree respects assume-unchanged and
47
+ // skip-worktree, so compare the actual bytes to the index blobs instead.
48
+ const stagedResult = run(['diff', '--cached', '--no-renames', '--no-ext-diff', '--no-textconv', '--name-only', '--relative', '-z']);
49
+ const indexResult = run(['ls-files', '--stage', '-z']);
50
+ if (stagedResult.error || stagedResult.status !== 0 || indexResult.error || indexResult.status !== 0) fail('E_GIT', 'Unable to inspect staged changes safely.');
51
+ const decode = buffer => {
52
+ const value = buffer.toString('utf8');
53
+ if (!Buffer.from(value, 'utf8').equals(buffer)) fail('E_GIT', 'Unable to safely inspect a non-UTF-8 staged path.');
54
+ return value;
55
+ };
56
+ const records = buffer => {
57
+ const result = []; let start = 0;
58
+ for (let i = 0; i < buffer.length; i++) if (buffer[i] === 0) { result.push(buffer.subarray(start, i)); start = i + 1; }
59
+ if (start !== buffer.length) fail('E_GIT', 'Unable to parse Git output safely.');
60
+ return result;
61
+ };
62
+ const paths = new Set(records(stagedResult.stdout).map(decode));
63
+ const entries = new Map();
64
+ for (const bytes of records(indexResult.stdout)) {
65
+ const tab = bytes.indexOf(9);
66
+ const record = bytes.subarray(0, tab).toString('ascii');
67
+ if (tab < 0) fail('E_GIT', 'Unable to parse the Git index safely.');
68
+ const match = /^(\d+) ([0-9a-f]+) ([0-3])$/.exec(record);
69
+ if (!match) fail('E_GIT', 'Unable to parse the Git index safely.');
70
+ const rawName = bytes.subarray(tab + 1);
71
+ const name = rawName.toString('utf8');
72
+ if (!paths.has(name)) continue;
73
+ if (!Buffer.from(name, 'utf8').equals(rawName)) fail('E_GIT', 'Unable to safely inspect a non-UTF-8 staged path.');
74
+ if (entries.has(name) || match[3] !== '0') entries.set(name, null);
75
+ else entries.set(name, { mode: match[1], oid: match[2] });
76
+ }
77
+ const divergent = new Set();
78
+ divergent.deleted = new Set();
79
+ for (const name of paths) {
80
+ if (prefix && !name.startsWith(`${prefix}/`)) continue;
81
+ const path = prefix ? name.slice(prefix.length + 1) : name;
82
+ const entry = entries.get(name);
83
+ // Distinguish a staged deletion from unresolved index stages.
84
+ if (!entries.has(name)) { divergent.add(path); divergent.deleted.add(path); continue; }
85
+ if (!entry || !['100644', '100755'].includes(entry.mode) || ![40, 64].includes(entry.oid.length)) { divergent.add(path); continue; }
86
+ let fd;
87
+ try {
88
+ const file = safePath(root, path), info = stat(file);
89
+ if (!info?.isFile()) { divergent.add(path); continue; }
90
+ fd = openSync(file, 'r');
91
+ const before = fstatSync(fd);
92
+ const digest = createHash(entry.oid.length === 64 ? 'sha256' : 'sha1').update(`blob ${before.size}\0`);
93
+ const buffer = Buffer.allocUnsafe(64 * 1024);
94
+ let count = 0, n;
95
+ while ((n = readSync(fd, buffer, 0, buffer.length, null)) !== 0) { digest.update(buffer.subarray(0, n)); count += n; }
96
+ const after = fstatSync(fd);
97
+ if (count !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) { divergent.add(path); continue; }
98
+ if (digest.digest('hex') !== entry.oid) {
99
+ const indexed = run(['cat-file', 'blob', entry.oid]);
100
+ const working = readFileSync(file), latest = fstatSync(fd);
101
+ if (indexed.error || indexed.status !== 0) fail('E_GIT', 'Unable to inspect staged content safely.');
102
+ if (latest.size !== after.size || latest.mtimeMs !== after.mtimeMs || !indexContentMatches(root, path, working, indexed.stdout)) divergent.add(path);
103
+ }
104
+ } catch (error) {
105
+ // Treat paths that cannot be inspected (including symbolic links) as
106
+ // divergent; the normal safety checks still reject unsafe writes.
107
+ if (error.code === 'E_SYMLINK' || error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'E_PATH') divergent.add(path);
108
+ else throw error;
109
+ } finally { if (fd !== undefined) closeSync(fd); }
110
+ }
111
+ return divergent;
112
+ }
113
+ // A staged name may use another case for the same path on macOS or Windows.
114
+ // Probe an existing directory only; do not create files during a dry run.
115
+ function stagedContains(root, paths, path) {
116
+ if (paths.has(path)) return true;
117
+ let current = root;
118
+ while (!stat(current) && dirname(current) !== current) current = dirname(current);
119
+ while (dirname(current) !== current) {
120
+ const base = current.slice(dirname(current).length + 1);
121
+ const index = base.search(/[A-Za-z]/);
122
+ if (index >= 0) {
123
+ const letter = base[index];
124
+ const other = letter === letter.toUpperCase() ? letter.toLowerCase() : letter.toUpperCase();
125
+ const alternative = join(dirname(current), `${base.slice(0, index)}${other}${base.slice(index + 1)}`);
126
+ const a = stat(current), b = stat(alternative);
127
+ if (!a || !b || a.dev !== b.dev || a.ino !== b.ino) return false;
128
+ return [...paths].some(candidate => candidate.toLowerCase() === path.toLowerCase());
129
+ }
130
+ current = dirname(current);
131
+ }
132
+ // If no existing name can be probed, preserve on ambiguity.
133
+ return [...paths].some(candidate => candidate.toLowerCase() === path.toLowerCase());
134
+ }
18
135
  const ignored = new Set(['.git', '.local', '.cache', 'node_modules', '.agent-tests', 'opencode-loop']);
19
136
  const reservedTargets = new Set(['.git', 'node_modules', '.pbai4devs']);
20
137
  const checkTarget = path => {
@@ -71,7 +188,10 @@ function walk(root, path = '', filter = () => true) {
71
188
  for (const name of readdirSync(directory).sort()) {
72
189
  const child = path ? `${path}/${name}` : name;
73
190
  const info = lstatSync(join(root, child));
74
- if (info.isSymbolicLink()) fail('E_SYMLINK', `Symbolic link refused: ${child}`);
191
+ if (info.isSymbolicLink()) {
192
+ if (!filter(child, false) && !filter(child, true)) continue;
193
+ fail('E_SYMLINK', `Symbolic link refused: ${child}`);
194
+ }
75
195
  if (!filter(child, info.isDirectory())) continue;
76
196
  if (info.isDirectory()) files.push(...walk(root, child, filter));
77
197
  else if (info.isFile()) files.push(child);
@@ -109,7 +229,7 @@ function read(root, path) {
109
229
  }
110
230
 
111
231
  function allowedManaged(path) {
112
- if (path === '.pbai4devs/estado/README.md') return true;
232
+ if (['.pbai4devs/estado/README.md', '.pbai4devs/adapters/README.md'].includes(path)) return true;
113
233
  if (canonicalEntrypoint(path) || path === '.gitignore') return true;
114
234
  if (nativeMarkdown(path) || nativeToml(path)) return true;
115
235
  if (['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf'].some(name => path === `.pbai4devs/${name}`)) return true;
@@ -316,6 +436,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
316
436
  ?? (canonicalEntrypoint(path) ? Object.entries(knownBlocks).find(([candidate]) => canonicalEntrypoint(candidate) === canonicalEntrypoint(path))?.[1] : undefined);
317
437
  const preserved = new Map();
318
438
  const recordPreserved = (path, reason) => { preserved.set(path, { path, reason }); return true; };
439
+ const stagedHuman = stagedDivergences(target);
319
440
  const native = path => canonicalEntrypoint(path) || path === 'README.md' || adapterRoots.some(root => path === root || path.startsWith(`${root}/`));
320
441
  const boundary = path => {
321
442
  if (!native(path)) return false;
@@ -353,8 +474,27 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
353
474
  }
354
475
  const supplied = new Set(bundled);
355
476
  const internal = new Set(['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf', '.gitignore', 'state/README.md', 'estado/README.md']);
356
- if (bundled.length) for (const path of Object.keys(previous?.files || {})) {
357
- if (path.startsWith('.pbai4devs/') && !supplied.has(path.slice(11)) && !internal.has(path.slice(11))) rmSync(safePath(stage, path), { force: true });
477
+ // A pathname, matching contents, or --force alone do not establish that a
478
+ // project's hub source belongs to the kit. Restore human files *before*
479
+ // deriving native pointers so that the staged catalogue describes the
480
+ // source that will actually remain in the project.
481
+ const preservedHub = new Set();
482
+ for (const relative of existing) {
483
+ const path = `.pbai4devs/${relative}`;
484
+ if (internal.has(relative)) continue;
485
+ const current = read(target, path);
486
+ if (!current || previous?.files?.[path] === hash(current) && !stagedContains(target, stagedHuman, path)) continue;
487
+ if (supplied.has(relative)) write(stage, path, current);
488
+ preservedHub.add(path);
489
+ recordPreserved(path, stagedContains(target, stagedHuman, path) ? 'staged-content-differs' : previous?.files?.[path] ? 'edited-managed-file' : 'existing-file');
490
+ }
491
+ if (bundled.length) for (const [path, digest] of Object.entries(previous?.files || {})) {
492
+ if (!path.startsWith('.pbai4devs/') || supplied.has(path.slice(11)) || internal.has(path.slice(11))) continue;
493
+ const current = read(target, path);
494
+ if (current && (digest !== hash(current) || stagedContains(target, stagedHuman, path))) {
495
+ preservedHub.add(path);
496
+ recordPreserved(path, stagedContains(target, stagedHuman, path) ? 'staged-content-differs' : 'edited-managed-file');
497
+ } else rmSync(safePath(stage, path), { force: true });
358
498
  }
359
499
  for (const root of active.roots) {
360
500
  if (preserveNative(`${root}/.gitignore`)) continue;
@@ -432,7 +572,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
432
572
  const manifest = { schemaVersion: 1, version: bundled.length ? packageInfo.version : previous.version, language, files: {}, blocks: {} };
433
573
  if (mode !== 'sync' || clients !== undefined || previous?.clients !== undefined) manifest.clients = selected;
434
574
  for (const [path, content] of desired) {
435
- if (path === 'README.md' || migratedHuman.has(path)) continue;
575
+ if (path === 'README.md' || migratedHuman.has(path) || preservedHub.has(path)) continue;
436
576
  const block = blockDigest(path, content);
437
577
  if (block) manifest.blocks[path] = block;
438
578
  else manifest.files[path] = hash(content);
@@ -460,8 +600,8 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
460
600
  if (aliasedEntrypoint(path)) continue;
461
601
  const current = read(target, path); if (!current) continue;
462
602
  const known = wholeRecords.some(record => record[path] === hash(current));
463
- if (nativeManagedText(path) && !known) { recordPreserved(path, 'existing-file'); continue; }
464
- if (!known) conflicts.push(path);
603
+ if (stagedContains(target, stagedHuman, path)) { recordPreserved(path, 'staged-content-differs'); continue; }
604
+ if (!known) { recordPreserved(path, 'edited-managed-file'); continue; }
465
605
  changes.push({ path, action: 'remove', content: null, beforeHash: hash(current) });
466
606
  }
467
607
  for (const [recordedPath, digest] of Object.entries(knownBlocks)) {
@@ -509,13 +649,53 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
509
649
  if (preserved.has(full) || !known || !generatedReference(current)) { recordPreserved(full, 'existing-file'); continue; }
510
650
  changes.push({ path: full, action: 'remove', content: null, beforeHash: hash(current) });
511
651
  }
512
- const legacy = '.pbai4devs/adapters/README.md', legacyContent = read(target, legacy);
513
- if (legacyContent) {
514
- const known = legacyContent.toString('utf8').includes('# Adaptadores') && /Antigravity|metadados/.test(legacyContent.toString('utf8'));
515
- if (!known) conflicts.push(legacy);
516
- changes.push({ path: legacy, action: 'remove', content: null, beforeHash: hash(legacyContent) });
652
+ const legacy = '.pbai4devs/adapters/README.md', legacyRoot = '.pbai4devs/adapters';
653
+ const legacyRootInfo = stat(join(target, legacyRoot));
654
+ const legacyInfo = legacyRootInfo?.isDirectory() ? stat(join(target, legacy)) : null;
655
+ if (legacyRootInfo && !legacyRootInfo.isDirectory()) recordPreserved(legacyRoot, legacyRootInfo.isSymbolicLink() ? 'symbolic-link' : 'existing-file');
656
+ if (legacyInfo && !legacyInfo.isFile()) recordPreserved(legacy, legacyInfo.isSymbolicLink() ? 'symbolic-link' : 'existing-directory');
657
+ const legacyContent = legacyInfo?.isFile() ? read(target, legacy) : null;
658
+ if (legacyContent && !changes.some(change => change.path === legacy)) {
659
+ if (!stagedContains(target, stagedHuman, legacy) && previous?.files?.[legacy] === hash(legacyContent)) changes.push({ path: legacy, action: 'remove', content: null, beforeHash: hash(legacyContent) });
660
+ else recordPreserved(legacy, stagedContains(target, stagedHuman, legacy) ? 'staged-content-differs' : 'existing-file');
517
661
  }
518
662
  for (const item of migration?.files || []) changes.push({ path: item.path, action: 'remove', content: null, beforeHash: hash(item.content) });
663
+ // The generated catalogues must agree with the installed sources. A staged
664
+ // deletion has no indexed bytes to overwrite; allow their regeneration only
665
+ // when the current file exactly matches the previously owned digest.
666
+ const generatedCatalogues = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
667
+ for (const change of changes) if (change.action === 'update' && generatedCatalogues.has(change.path)
668
+ && stagedContains(target, stagedHuman.deleted || new Set(), change.path)
669
+ && change.beforeHash && previous?.files?.[change.path] === change.beforeHash) change.stagedDeletionAllowed = true;
670
+ for (const change of changes) {
671
+ if (!stagedContains(target, stagedHuman, change.path)) continue;
672
+ const current = read(target, change.path);
673
+ if (change.action === 'update' && current && change.content && nativeManagedText(change.path)) {
674
+ // Preserve every byte outside a known managed block. Appending a block
675
+ // is also safe when the complete existing file is an exact prefix.
676
+ if (!nativeToml(change.path) && change.content.subarray(0, current.length).equals(current)) {
677
+ try {
678
+ const digest = managedBlockDigest(change.content);
679
+ const stripped = digest && stripManagedContent(change.content, digest);
680
+ const extra = stripped?.slice(current.toString('utf8').length);
681
+ if (stripped?.startsWith(current.toString('utf8')) && extra !== undefined && /^[\r\n]*$/.test(extra)) change.stagedBlockAllowed = true;
682
+ } catch { /* Invalid or unproven block: keep the path blocked. */ }
683
+ } else if (knownBlocks[change.path]) {
684
+ try {
685
+ const strip = nativeToml(change.path) ? stripTomlContent : stripManagedContent;
686
+ if (Buffer.from(strip(current, knownBlocks[change.path])).equals(change.content)) change.stagedBlockAllowed = true;
687
+ } catch { /* An edited block is not safe to remove. */ }
688
+ }
689
+ }
690
+ if (change.action === 'remove' && migration) {
691
+ const item = migration.files.find(file => file.path === change.path);
692
+ const copy = item && changes.find(candidate => candidate.path === item.target && candidate.action === 'create');
693
+ if (item && current?.equals(item.content) && copy?.content?.equals(item.content)
694
+ && changes.indexOf(copy) < changes.indexOf(change)) change.stagedMigrationTarget = item.target;
695
+ }
696
+ }
697
+ const stagedChanges = changes.filter(change => stagedContains(target, stagedHuman, change.path) && !change.stagedDeletionAllowed && !change.stagedBlockAllowed && !change.stagedMigrationTarget);
698
+ if (stagedChanges.length) fail('E_STAGED', `Staged content differs from the working tree for planned changes: ${stagedChanges.map(change => `${change.action} ${change.path}`).join(', ')}. Preserve and review these paths before updating.`);
519
699
  const retiredRoots = adapterRoots.filter(root => !active.roots.includes(root) && changes.some(change => change.action === 'remove' && change.path.startsWith(`${root}/`) && (previous?.files[change.path] || previous?.blocks[change.path])));
520
700
  const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])].filter(path => !mixedPaths.has(path) && !overlapsPreserved(path));
521
701
  const hashes = {};
@@ -539,6 +719,24 @@ export function applyPlan(plan) {
539
719
  if (!plan.canApply) fail('E_CONFLICT', text(plan.language, 'conflict'));
540
720
  assertLocalIgnoreUnchanged(plan.localIgnore);
541
721
  const { target } = plan;
722
+ const stagedHuman = stagedDivergences(target);
723
+ const currentState = loadState(target);
724
+ const stagedChanges = plan.changes.filter(change => {
725
+ if (!stagedContains(target, stagedHuman, change.path)) return false;
726
+ const current = read(target, change.path);
727
+ if ((current ? hash(current) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
728
+ if (!current) return true;
729
+ if (change.stagedDeletionAllowed && stagedContains(target, stagedHuman.deleted || new Set(), change.path)
730
+ && currentState?.files?.[change.path] === change.beforeHash) return false;
731
+ if (change.stagedBlockAllowed) return false; // Exact source and planned bytes were checked above.
732
+ if (change.stagedMigrationTarget) {
733
+ const copy = plan.changes.find(candidate => candidate.path === change.stagedMigrationTarget && candidate.action === 'create');
734
+ if (copy?.content?.equals(current) && plan.changes.indexOf(copy) < plan.changes.indexOf(change)
735
+ && !stat(safePath(target, change.stagedMigrationTarget))) return false;
736
+ }
737
+ return true;
738
+ });
739
+ if (stagedChanges.length) fail('E_STAGED', `Staged content differs from the working tree for planned changes: ${stagedChanges.map(change => `${change.action} ${change.path}`).join(', ')}. Preserve and review these paths before updating.`);
542
740
  if (plan.migration) {
543
741
  if (stat(safePath(target, '.pbai4devs/state'))) fail('E_CHANGED', 'State destination appeared after planning.');
544
742
  for (const directory of plan.migration.directories) {
@@ -574,7 +772,18 @@ export function applyPlan(plan) {
574
772
  const file = safePath(target, change.path);
575
773
  const content = change.content;
576
774
  const signature = { path: change.path, hash: content === null ? null : hash(content) };
577
- if (content === null) { rmSync(file, { force: true }); applied.push(signature); }
775
+ if (content === null) {
776
+ const migrated = plan.migration?.files.find(item => item.path === change.path);
777
+ if (migrated) {
778
+ const copy = plan.changes.find(candidate => candidate.path === migrated.target && candidate.action === 'create');
779
+ const copied = read(target, migrated.target);
780
+ if (!copy?.content || !copied?.equals(copy.content)
781
+ || migrated.path !== legacyStatePath && !copied.equals(before.get(change.path))) {
782
+ fail('E_CHANGED', `Migrated copy changed before removing: ${change.path}`);
783
+ }
784
+ }
785
+ rmSync(file, { force: true }); applied.push(signature);
786
+ }
578
787
  else atomicWrite(target, change.path, content, beforeModes.get(change.path) ?? change.mode, mode => {
579
788
  signature.mode = mode; applied.push(signature);
580
789
  }, () => assertOriginal(change.path));