@pathbit/agentdevkit 0.0.17 → 0.0.19

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) {
@@ -309,26 +332,6 @@ function ambiguousExceptions(root, location, clientRoots, artifacts) {
309
332
  return conflicts;
310
333
  }
311
334
 
312
- function foreignHiddenClients(plan) {
313
- if (!plan.location) return [];
314
- const content = localRead(plan.location.exclude) || '', ranges = [];
315
- let start = null;
316
- content.split(/\r?\n/).forEach((line, index) => {
317
- const begin = line.match(/^# BEGIN AGENTDEVKIT LOCAL ([a-f0-9]{16})$/);
318
- const end = line.match(/^# END AGENTDEVKIT LOCAL ([a-f0-9]{16})$/);
319
- if (begin) start = { id: begin[1], line: index + 1 };
320
- else if (end && start?.id === end[1]) { ranges.push({ ...start, end: index + 1 }); start = null; }
321
- });
322
- const probes = plan.clientRoots.filter(path => { const item = localStat(join(plan.root, path)); return !item || item.isDirectory() && !item.isSymbolicLink(); }).map(path => `${path}/.agentdevkit-human-probe.md`);
323
- if (!probes.length) return [];
324
- const { paths, matches } = ignoreMatches(plan.location, probes);
325
- return paths.flatMap((path, index) => {
326
- const match = matches.get(path);
327
- if (!match || match.pattern.startsWith('!') || resolve(plan.location.top, match.source) !== plan.location.exclude) return [];
328
- return ranges.some(range => `# BEGIN AGENTDEVKIT LOCAL ${range.id}` !== plan.first && match.line >= range.line && match.line <= range.end) ? [probes[index]] : [];
329
- });
330
- }
331
-
332
335
  function localIgnoreSource(location, source) {
333
336
  const path = resolve(location.top, source);
334
337
  if (path === location.exclude) return true;
@@ -347,6 +350,21 @@ function ignoreMatches(location, probes) {
347
350
  return { paths, matches };
348
351
  }
349
352
 
353
+ // A nested .gitignore cannot reopen a project hidden by an ignored ancestor.
354
+ // Detect this before claiming that its client exceptions are effective.
355
+ function ignoredProjectAncestors(location) {
356
+ if (!location.prefix) return [];
357
+ const segments = location.prefix.split('/');
358
+ const paths = segments.map((_part, index) => segments.slice(0, index + 1).join('/'));
359
+ const result = gitCommand(location.top, ['check-ignore', '--verbose', '--no-index', '--stdin', '-z'], `${paths.join('\0')}\0`);
360
+ if (result.error || ![0, 1].includes(result.status)) throw new Error(result.error?.message || result.stderr || 'Falha ao conferir ancestrais Git.');
361
+ const values = result.stdout.split('\0'), ignored = [];
362
+ for (let index = 0; index + 3 < values.length; index += 4) {
363
+ if (!values[index + 2].startsWith('!')) ignored.push(`Ancestral Git ignorado: ${values[index + 3]}/; reabra-o no .gitignore do repositorio pai`);
364
+ }
365
+ return ignored;
366
+ }
367
+
350
368
  function localRoot(value) {
351
369
  const missing = []; let parent = resolve(value);
352
370
  while (!localStat(parent)) { missing.unshift(parent.slice(dirname(parent).length + 1)); parent = dirname(parent); }
@@ -364,15 +382,17 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
364
382
  const artifacts = artifactPolicy(templateFile);
365
383
  if (template.split('{{OUTPUTS}}').length !== 2) throw new Error('local.ignore exige um unico {{OUTPUTS}}.');
366
384
  if (template.split('{{ROOTS}}').length > 2) throw new Error('local.ignore permite um unico {{ROOTS}}.');
367
- // A client directory can also contain human instructions and settings. Only
368
- // whole files owned by the kit belong in the local output exclusion list.
369
- const roots = [];
385
+ // Client roots are ignored locally. Projects can explicitly opt in to a
386
+ // complete client with a literal negation in their own root .gitignore.
370
387
  const localPolicy = join(templateRoot || root, '.pbai4devs/templates/clients/ADAPTERS.json');
371
388
  const policy = JSON.parse(readFileSync(checkedLocalPath(localStat(localPolicy) ? localPolicy : fileURLToPath(new URL('../../templates/clients/ADAPTERS.json', import.meta.url))), 'utf8'));
372
389
  const clientRoots = [...new Set(policy.adapters.flatMap(adapter => [adapter.root, ...(adapter.extraRoots || [])]))];
373
390
  if (clientRoots.some(path => typeof path !== 'string' || !/^\.[a-z0-9][a-z0-9-]*$/.test(path) || ['.git', '.pbai4devs', '.agent-tests'].includes(path))) throw new Error('Raiz de cliente invalida para exclusao.');
391
+ const roots = [...clientRoots];
374
392
  const privateClients = privateClientPolicy(templateFile, clientRoots);
375
- const paths = [...new Set([...outputs.map(checkedOutput), ...template.split(/\r?\n/).filter(line => line.startsWith('/')).map(line => checkedOutput(line.slice(1)))])].sort();
393
+ const outputPaths = [...new Set(outputs.map(checkedOutput))];
394
+ const paths = [...new Set([...outputPaths, ...template.split(/\r?\n/).filter(line => line.startsWith('/')).map(line => checkedOutput(line.slice(1)))])]
395
+ .filter(path => !roots.some(client => path === client || path.startsWith(`${client}/`))).sort();
376
396
  const info = gitCommand(resolved.parent, ['rev-parse', '--show-toplevel', '--git-path', 'info/exclude']);
377
397
  let location = null;
378
398
  if (info.status === 0) {
@@ -389,6 +409,19 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
389
409
  throw new Error(`Nao foi possivel localizar Git: ${info.error?.message || info.stderr}`);
390
410
  }
391
411
  const id = createHash('sha256').update(root).digest('hex').slice(0, 16);
412
+ const projectIgnore = join(root, '.gitignore'), projectIgnoreStat = localStat(projectIgnore);
413
+ const literalExceptions = projectIgnoreStat?.isFile() && !projectIgnoreStat.isSymbolicLink()
414
+ ? new Set(readFileSync(projectIgnore, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)) : new Set();
415
+ const requestedRoots = location ? roots.filter(client => literalExceptions.has(`!/${client}/`) || literalExceptions.has(`!${client}/`)) : [];
416
+ const optCandidates = requestedRoots.filter(client => safeIgnoreProbe(root, `${client}/.agentdevkit-opt-in-probe`));
417
+ // A later rule or an ancestor can override a literal negation. Consult Git
418
+ // before accepting it, including when the client folder does not yet exist.
419
+ const optProbes = optCandidates.map(client => `${client}/.agentdevkit-opt-in-probe`);
420
+ const optMatches = location && optProbes.length ? ignoreMatches(location, optProbes) : { paths: [], matches: new Map() };
421
+ const optedRoots = optCandidates.filter((_client, index) => {
422
+ const match = optMatches.matches.get(optMatches.paths[index]);
423
+ return !match || match.pattern.startsWith('!');
424
+ });
392
425
  const first = `# BEGIN AGENTDEVKIT LOCAL ${id}`, last = `# END AGENTDEVKIT LOCAL ${id}`;
393
426
  const fixed = template.replace('{{ROOTS}}', roots.map(path => `/${path}/`).join('\n')).replace('{{OUTPUTS}}', paths.filter(path => !roots.some(root => path === root || path.startsWith(root + '/'))).map(path => `/${escapePattern(path)}`).join('\n'))
394
427
  + '\n# Estado privado dos clientes\n' + privateClients.rules.join('\n') + '\n'
@@ -401,10 +434,9 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
401
434
  const target = location?.exclude;
402
435
  const before = target ? localRead(target) : null, after = target ? localBlock(before, first, last, body) : null;
403
436
  const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
404
- const probes = [...paths, ...rootProbes(root, roots)];
437
+ const probes = [...paths, ...rootProbes(root, roots.filter(client => !optedRoots.includes(client)))];
405
438
  const existingPrivate = privateClients.probes.filter(path => localStat(join(root, path.split('/')[0]))?.isDirectory() && safeIgnoreProbe(root, path));
406
439
  const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...existingPrivate, ...(location ? [...visibleArtifactProbes(root, location, artifacts), ...visiblePrivateProbes(root, location, clientRoots)] : [])])];
407
- const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
408
440
  if (location && !before?.includes(first)) {
409
441
  const ignored = ignoreMatches(location, [...probes, ...artifactPaths].filter(path => safeIgnoreProbe(root, path)));
410
442
  if (ignored.paths.every(path => { const match = ignored.matches.get(path); return match && !match.pattern.startsWith('!') && localIgnoreSource(location, match.source); })) edits.length = 0;
@@ -413,14 +445,16 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
413
445
  const tracked = location ? gitCommand(location.top, ['ls-files', '-z']).stdout.split('\0').filter(path => {
414
446
  if (!path.startsWith(prefix)) return false;
415
447
  const local = path.slice(prefix.length);
416
- return wanted.has(path) || artifactPath(local, artifacts) || clientRoots.includes(local.split('/')[0]) && isGeneratedOutput(local);
448
+ // Git ignore never untracks existing files. Report private data and test
449
+ // artifacts, but don't flag legitimate already-tracked project content.
450
+ return artifactPath(local, artifacts) || clientRoots.includes(local.split('/')[0]) && isGeneratedOutput(local);
417
451
  }).map(path => path.slice(prefix.length)) : [];
418
- return { root, status: location ? 'git' : 'no-git', location, paths, roots, clientRoots, artifactPatterns: artifacts, artifactProbes: artifactPaths, privateProbes: privateClients.probes, edits, trackedOutputs: tracked, first, last };
452
+ return { root, status: location ? 'git' : 'no-git', location, paths, outputPaths, roots, requestedRoots, optedRoots, clientRoots, artifactPatterns: artifacts, artifactProbes: artifactPaths, privateProbes: privateClients.probes, edits, trackedOutputs: tracked, first, last };
419
453
  }
420
454
 
421
455
  export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
422
456
  const missing = [];
423
- if (plan.location) missing.push(...ambiguousExceptions(plan.root, plan.location, plan.clientRoots || [], plan.artifactPatterns || []), ...foreignHiddenClients(plan));
457
+ if (plan.location) missing.push(...((plan.requestedRoots || []).length ? ignoredProjectAncestors(plan.location) : []), ...ambiguousExceptions(plan.root, plan.location, plan.clientRoots || [], plan.artifactPatterns || []));
424
458
  const replacements = replacedBlocks.map(item => {
425
459
  const path = canonicalExisting(checkedLocalPath(resolve(item.path))), inside = relative(localRoot(plan.root).root, path);
426
460
  if (isAbsolute(inside) || inside === '..' || inside.startsWith(`..${sep}`) || !Number.isInteger(item.firstLine) || !Number.isInteger(item.lastLine) || item.firstLine < 1 || item.lastLine < item.firstLine || !/^[a-f0-9]{64}$/.test(item.sha256)) throw new Error('Faixa de ignore gerenciado invalida.');
@@ -433,9 +467,9 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
433
467
  return { ...item, path };
434
468
  });
435
469
  if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
436
- missing.push(...plan.paths.filter(path => !safeIgnoreProbe(plan.root, path)));
470
+ missing.push(...new Set([...plan.paths, ...(plan.outputPaths || [])].filter(path => !safeIgnoreProbe(plan.root, path))));
437
471
  const privateProbes = (plan.privateProbes || []).filter(path => localStat(join(plan.root, path.split('/')[0]))?.isDirectory() && safeIgnoreProbe(plan.root, path));
438
- const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || []), ...privateProbes,
472
+ const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, (plan.roots || []).filter(client => !(plan.optedRoots || []).includes(client))), ...(plan.artifactProbes || []), ...privateProbes,
439
473
  ...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : []),
440
474
  ...visiblePrivateProbes(plan.root, plan.location, plan.clientRoots || [])])].filter(path => safeIgnoreProbe(plan.root, path));
441
475
  const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
@@ -458,12 +492,13 @@ export function assertLocalIgnoreUnchanged(plan) {
458
492
  }
459
493
  }
460
494
 
461
- function atomicLocalWrite(path, content, mode, published) {
495
+ function atomicLocalWrite(path, content, mode, published, beforePublish) {
462
496
  const temporary = `${path}.agentdevkit-${randomUUID()}`;
463
497
  try {
464
498
  writeFileSync(temporary, content, { flag: 'wx' });
465
499
  if (mode !== null && mode !== undefined) chmodSync(temporary, mode);
466
500
  const writtenMode = localMode(temporary);
501
+ beforePublish?.();
467
502
  renameSync(temporary, path);
468
503
  published?.(writtenMode);
469
504
  } finally { rmSync(temporary, { force: true }); }
@@ -477,7 +512,10 @@ export function applyLocalIgnore(plan) {
477
512
  try {
478
513
  for (const edit of plan.edits) {
479
514
  mkdirSync(dirname(edit.path), { recursive: true });
480
- atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }));
515
+ atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }), () => {
516
+ checkedLocalPath(edit.path);
517
+ if (localRead(edit.path) !== edit.before || localMode(edit.path) !== edit.beforeMode) throw new Error(`Exclusao alterada antes de publicar; conteudo preservado: ${edit.path}`);
518
+ });
481
519
  }
482
520
  const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
483
521
  if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
@@ -486,7 +524,12 @@ export function applyLocalIgnore(plan) {
486
524
  for (const { edit, mode } of written.reverse()) {
487
525
  try {
488
526
  if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) continue;
489
- if (edit.before === null) rmSync(edit.path, { force: true }); else atomicLocalWrite(edit.path, edit.before, edit.beforeMode);
527
+ const checkPublished = () => {
528
+ checkedLocalPath(edit.path);
529
+ if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) throw new Error(`Exclusao alterada durante rollback; conteudo preservado: ${edit.path}`);
530
+ };
531
+ if (edit.before === null) { checkPublished(); rmSync(edit.path, { force: true }); }
532
+ else atomicLocalWrite(edit.path, edit.before, edit.beforeMode, undefined, checkPublished);
490
533
  } catch (failure) { rollbackErrors.push(failure); }
491
534
  }
492
535
  if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
@@ -502,25 +545,39 @@ export function planUntrack(root, expectedHashes = {}) {
502
545
  if (!candidates.length || !localStat(root)) return { root, paths: [], hashes: {}, index: '', conflicts: [] };
503
546
  const result = gitCommand(root, ['ls-files', '--stage', '-z']);
504
547
  if (result.status !== 0) {
505
- if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || '')) return { root, paths: [], hashes: {}, index: '', conflicts: [] };
548
+ if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || '')) {
549
+ // Fail closed if a repository exists even when Git cannot be launched.
550
+ let directory = root;
551
+ while (true) {
552
+ if (localStat(join(directory, '.git'))) throw new Error('Nao foi possivel consultar o Git deste projeto; indice e arquivos preservados.');
553
+ const parent = dirname(directory);
554
+ if (parent === directory) break;
555
+ directory = parent;
556
+ }
557
+ return { root, paths: [], hashes: {}, index: '', conflicts: [] };
558
+ }
506
559
  throw new Error(result.error?.message || result.stderr || 'Falha ao ler indice Git.');
507
560
  }
508
561
  const conflicts = [];
509
562
  const records = result.stdout.split('\0').filter(Boolean);
510
- const wanted = new Set(candidates);
563
+ const folded = new Map();
564
+ for (const path of candidates) folded.set(path.toLowerCase(), [...(folded.get(path.toLowerCase()) || []), path]);
511
565
  const matches = new Map();
512
566
  for (const record of records) {
513
567
  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]);
568
+ if (position < 0) continue;
569
+ for (const candidate of folded.get(path.toLowerCase()) || []) {
570
+ matches.set(candidate, [...(matches.get(candidate) || []), record]);
571
+ }
516
572
  }
517
573
  const paths = [...matches.keys()].filter(path => {
518
574
  const file = checkedLocalPath(join(root, path));
519
575
  const entries = matches.get(path);
520
576
  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];
577
+ const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]], undefined, null) : null;
578
+ const data = localStat(file) ? readFileSync(file) : null;
579
+ const owned = data && createHash('sha256').update(data).digest('hex') === expectedHashes[path];
580
+ const indexOwned = entries[0]?.slice(entries[0].indexOf('\t') + 1) === path && blob?.status === 0 && data && indexContentMatches(root, path, data, blob.stdout);
524
581
  if (!owned || !indexOwned) conflicts.push(path);
525
582
  return owned && indexOwned;
526
583
  });
@@ -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
@@ -35,12 +38,14 @@ Após a instalação, depende somente de Node e do hub local, sem o pacote npm.
35
38
  Ele respeita os clientes escolhidos, sem recriar os desmarcados. Para trocar
36
39
  a seleção, use `agentdevkit agents <lista>`; não edite o manifesto gerado.
37
40
 
38
- Os derivados ficam locais: catálogos, manifesto, estado e apontamentos são
39
- excluídos pelo Git. O bloco em `.git/info/exclude` preserva regras próprias;
40
- sem Git, informa que a proteção fica pendente até `git init` e sincronização.
41
- Somente arquivos integralmente gerados são excluídos; pastas de clientes podem
42
- conter arquivos humanos versionados. Conteúdo próprio não é desindexado pela
43
- presença de um bloco gerenciado.
41
+ O bloco local em `.git/info/exclude` ignora as pastas de clientes por padrão;
42
+ sem Git, a proteção fica pendente até `git init` e sincronização. Para incluir
43
+ uma pasta, use uma exceção literal no `.gitignore` da raiz, como `!/.claude/`.
44
+ Isso também vale para `.github` e `.vscode`, que podem conter arquivos humanos.
45
+ Arquivos já rastreados e o índice não são alterados automaticamente.
44
46
  Entradas novas usam os templates; hashes em `state/derived.json` permitem
45
47
  atualizar arquivos gerados e blocos separadamente, preservando alterações humanas.
46
- `--check` também acusa derivados ainda rastreados; não altera o índice.
48
+ `--check` somente verifica; não altera o índice.
49
+ Apontamentos obsoletos podem ser removidos quando a propriedade é comprovada;
50
+ arquivos e pastas próprios, inclusive os adicionais dentro do mesmo recurso,
51
+ devem permanecer.
@@ -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); }