@pathbit/agentdevkit 0.0.11 → 0.0.13

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.
Files changed (32) hide show
  1. package/.pbai4devs/rules/documentacao/RULE.md +2 -0
  2. package/.pbai4devs/scripts/lib/README.md +8 -0
  3. package/.pbai4devs/scripts/lib/gitignore.mjs +66 -7
  4. package/.pbai4devs/scripts/lib/managed-content.mjs +86 -0
  5. package/.pbai4devs/scripts/lib/managed-toml.mjs +192 -0
  6. package/.pbai4devs/scripts/sincronizar-agentes/README.md +9 -7
  7. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +133 -38
  8. package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +25 -15
  9. package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +7 -2
  10. package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +90 -4
  11. package/.pbai4devs/scripts/sincronizar-agentes/tests/MANAGED_CONTENT.mjs +318 -0
  12. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +47 -26
  13. package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +43 -7
  14. package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +3 -2
  15. package/CHANGELOG.md +14 -0
  16. package/README.en.md +2 -2
  17. package/README.es.md +2 -2
  18. package/README.md +7 -3
  19. package/docs/guides/cli.md +2 -1
  20. package/docs/guides/installation.md +27 -8
  21. package/docs/guides/troubleshooting.md +8 -5
  22. package/docs/maintainers/maintenance.md +5 -0
  23. package/docs/maintainers/packages.md +2 -2
  24. package/docs/product/design.md +10 -3
  25. package/docs/product/prd.md +2 -1
  26. package/docs/product/roadmap.md +2 -0
  27. package/docs/specs/README.md +5 -0
  28. package/docs/specs/additive-integration/README.md +9 -0
  29. package/docs/specs/additive-integration/SPEC.md +53 -0
  30. package/docs/specs/real-projects/SPEC.md +26 -0
  31. package/package.json +1 -1
  32. package/src/install.mjs +139 -63
@@ -8,6 +8,8 @@ import { buildCatalog } from '../lib/catalog.mjs';
8
8
  import { resourcePath } from '../lib/resource-path.mjs';
9
9
  import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
10
10
  import { nativePathBoundary } from '../lib/native-paths.mjs';
11
+ import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
12
+ import { mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
11
13
  import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
12
14
 
13
15
  const marker = '<!-- gerado: sincronizar-agentes -->';
@@ -72,7 +74,7 @@ function link(target, source) {
72
74
 
73
75
  export { resourcePath } from '../lib/resource-path.mjs';
74
76
 
75
- export function synchronize({ root = projectRoot, check = false, force = false, localIgnores = true, generateEntrypoints = true, output = console.log } = {}) {
77
+ export function synchronize({ root = projectRoot, check = false, force = false, localIgnores = true, generateEntrypoints = true, recordDerivedState = true, output = console.log } = {}) {
76
78
  requireNode();
77
79
  root = realpathSync(resolve(root));
78
80
  const absolute = value => join(root, ...value.split('/'));
@@ -95,7 +97,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
95
97
  const path = safe(value);
96
98
  const item = stat(path);
97
99
  if (!item) return [];
98
- if (item.isFile()) return !markdownOnly || value.endsWith('.md') ? [value] : [];
100
+ if (item.isFile()) return !markdownOnly || /\.md$/i.test(value) ? [value] : [];
99
101
  if (!item.isDirectory()) fail(`Tipo de arquivo nao suportado: ${value}`);
100
102
  const files = [];
101
103
  function visit(folder) {
@@ -107,22 +109,27 @@ export function synchronize({ root = projectRoot, check = false, force = false,
107
109
  if (info.isDirectory()) {
108
110
  files.push(...scan(child, { markdownOnly, prune }));
109
111
  } else if (info.isFile()) {
110
- if (!markdownOnly || name.endsWith('.md')) files.push(child);
112
+ if (!markdownOnly || /\.md$/i.test(name)) files.push(child);
111
113
  } else fail(`Tipo de arquivo nao suportado: ${child}`);
112
114
  }
113
115
  }
114
116
  visit(value);
115
117
  return files;
116
118
  }
117
- const referenceFile = value => {
119
+ const mergedInputs = new Map();
120
+ const captureInput = (path, bytes) => { if (!mergedInputs.has(path)) mergedInputs.set(path, bytes); };
121
+ const referenceFile = (value, supplied) => {
118
122
  const info = stat(safe(value));
119
123
  if (!info?.isFile() || info.size > 1024 * 1024) return false;
120
- const bytes = readFileSync(absolute(value)), text = normalize(bytes.toString('utf8'));
121
- const recorded = installed?.files?.[value] === createHash('sha256').update(bytes).digest('hex');
122
- const marked = text.split('\n').includes(marker) || text.startsWith(tomlMarker + '\n');
123
- return isReference(text) && (marked || recorded);
124
+ const bytes = supplied ?? readFileSync(absolute(value)), text = normalize(bytes.toString('utf8'));
125
+ captureInput(value, bytes);
126
+ const digest = createHash('sha256').update(bytes).digest('hex');
127
+ const recordedHash = priorFiles[value];
128
+ if (recordedHash && recordedHash !== digest) return false;
129
+ const recorded = recordedHash === digest;
130
+ return isReference(text) && recorded;
124
131
  };
125
- 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)$/.test(file) && referenceFile(file)); };
132
+ 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)); };
126
133
  const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
127
134
  const agents = new Map();
128
135
  const formats = new Map();
@@ -132,21 +139,40 @@ export function synchronize({ root = projectRoot, check = false, force = false,
132
139
  const legacy = [];
133
140
  const config = '.pbai4devs/espelhos.conf';
134
141
  const configExists = stat(safe(config));
142
+ captureInput(config, configExists ? readFileSync(absolute(config)) : null);
135
143
  const stateFile = '.pbai4devs/estado/agentdevkit.json';
136
144
  const installed = stat(safe(stateFile)) ? JSON.parse(read(safe(stateFile))) : null;
137
145
  const configText = configExists ? read(safe(config)) : adapterManifest(root, { clients: installed?.clients });
138
146
  const rows = configText.split('\n');
139
147
  const entryRegistry = '.pbai4devs/estado/derived.json';
140
- const previousEntries = stat(safe(entryRegistry)) ? JSON.parse(read(safe(entryRegistry))) : { schemaVersion: 1, entries: {} };
141
- if (previousEntries?.schemaVersion !== 1 || !previousEntries.entries || typeof previousEntries.entries !== 'object' || Array.isArray(previousEntries.entries) || Object.entries(previousEntries.entries).some(([name, hash]) => !['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'].includes(name) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de entradas derivadas invalido.');
148
+ const registryBytes = stat(safe(entryRegistry)) ? readFileSync(absolute(entryRegistry)) : null;
149
+ captureInput(entryRegistry, registryBytes);
150
+ const previousEntries = registryBytes ? JSON.parse(registryBytes.toString('utf8')) : { schemaVersion: 1, entries: {} };
151
+ if (previousEntries?.schemaVersion !== 1 || !previousEntries.entries || typeof previousEntries.entries !== 'object' || Array.isArray(previousEntries.entries) || Object.entries(previousEntries.entries).some(([name, hash]) => !canonicalEntrypoint(name) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de entradas derivadas invalido.');
142
152
  const previousOutputs = previousEntries.outputs ?? {};
143
153
  if (!previousOutputs || typeof previousOutputs !== 'object' || Array.isArray(previousOutputs) || Object.entries(previousOutputs).some(([path, hash]) => !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/(skills|rules|agents)\.json$/.test(path) || reserved.has(path.split('/')[0].toLowerCase()) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de saidas derivadas invalido.');
144
- const ownedPaths = new Set([...Object.keys(installed?.files || {}), ...Object.keys(installed?.blocks || {}), ...Object.keys(previousEntries.entries), ...Object.keys(previousOutputs)]);
154
+ const validResourcePath = path => {
155
+ if (typeof path !== 'string' || !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/.+/.test(path)) return false;
156
+ const [root, scope] = path.split('/');
157
+ return !reserved.has(root.toLowerCase()) && ![...auxiliary, 'workflows'].includes(scope.toLowerCase())
158
+ && !['readme.md', 'instructions.md'].includes(posix.basename(path).toLowerCase())
159
+ && (/\.(md|mdc)$/i.test(path) || path.startsWith('.gemini/commands/') && path.endsWith('.toml'));
160
+ };
161
+ for (const [name, values] of [['files', previousEntries.files ?? {}], ['blocks', previousEntries.blocks ?? {}]]) {
162
+ if (!values || typeof values !== 'object' || Array.isArray(values) || Object.entries(values).some(([path, hash]) => !(validResourcePath(path) || name === 'blocks' && canonicalEntrypoint(path)) || !/^[a-f0-9]{64}$/.test(hash))) fail(`Registro de ${name} derivados invalido.`);
163
+ for (const path of Object.keys(values)) safe(canonicalEntrypoint(path) ? resolveEntrypoint(root, canonicalEntrypoint(path)) : path);
164
+ }
165
+ const removedBlocks = new Set(previousEntries.removedBlocks ?? []);
166
+ if (!Array.isArray(previousEntries.removedBlocks ?? []) || [...removedBlocks].some(path => typeof path !== 'string' || !(validResourcePath(path) || canonicalEntrypoint(path)))) fail('Registro de blocos removidos invalido.');
167
+ for (const path of removedBlocks) safe(canonicalEntrypoint(path) ? resolveEntrypoint(root, canonicalEntrypoint(path)) : path);
168
+ const priorFiles = { ...installed?.files, ...previousEntries.files };
169
+ const priorBlocks = { ...Object.fromEntries(Object.entries(installed?.blocks ?? {}).filter(([path]) => !removedBlocks.has(path))), ...previousEntries.blocks };
170
+ const ownedPaths = new Set([...Object.keys(priorFiles), ...Object.keys(priorBlocks), ...Object.keys(previousEntries.entries), ...Object.keys(previousOutputs)]);
145
171
  const nativeRoots = new Set(rows.map(row => row.split('|')).filter(([type]) => ['agente', 'gitignore'].includes(type)).map(([, target]) => target));
146
172
  const preserved = new Map();
147
173
  const remember = (path, reason = 'user-owned') => { preserved.set(path, { path, reason }); return true; };
148
174
  function preserveBoundary(value) {
149
- if (!nativeRoots.has(value.split('/')[0]) && !['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'README.md'].includes(value)) return false;
175
+ if (!nativeRoots.has(value.split('/')[0]) && !canonicalEntrypoint(value) && value !== 'README.md') return false;
150
176
  const boundary = nativePathBoundary(root, value, ownedPaths, { preserveRootAliases: true });
151
177
  return boundary ? remember(boundary.path, boundary.reason) : false;
152
178
  }
@@ -160,6 +186,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
160
186
  function preserveUnknown(value) {
161
187
  if (preserveBoundary(value)) return true;
162
188
  const info = stat(safe(value));
189
+ if (info?.isFile() && (/\.(md|mdc)$/i.test(value) || value.startsWith('.gemini/commands/') && value.endsWith('.toml'))) return false;
163
190
  return info && !referenceFile(value) && !claimed(value) ? remember(value) : false;
164
191
  }
165
192
  function preserveNativeType(value, info, type) {
@@ -267,6 +294,13 @@ export function synchronize({ root = projectRoot, check = false, force = false,
267
294
  const backup = new Set();
268
295
  const conflicts = new Set();
269
296
  const expected = new Map();
297
+ const managedBlocks = {};
298
+ const generatedFiles = {};
299
+ const rememberGenerated = (target, content) => {
300
+ const hash = createHash('sha256').update(content).digest('hex');
301
+ if (installed?.files?.[target] !== hash || Object.hasOwn(previousEntries.files ?? {}, target)) generatedFiles[target] = hash;
302
+ };
303
+ const retiredBlocks = new Set();
270
304
  const changed = [];
271
305
  function planRemove(value, custom = false) {
272
306
  safe(value);
@@ -278,8 +312,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
278
312
  if (preserveBoundary(value)) return;
279
313
  if (!stat(safe(value))) return;
280
314
  if (stat(safe(value)).isDirectory() && !scan(value).length) return;
315
+ if (Object.keys(priorBlocks).some(path => path === value || path.startsWith(value + '/'))) return;
281
316
  if (onlyReferences(value)) planRemove(value);
282
- else if (claimed(value)) { if (force) planRemove(value, true); else conflicts.add(value); }
317
+ else if (claimed(value)) remember(value, 'edited-generated-file');
283
318
  else remember(value);
284
319
  }
285
320
  const registries = new Map();
@@ -287,7 +322,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
287
322
  for (const [target, content] of built.outputs) {
288
323
  const info = stat(safe(target));
289
324
  if (info && !info.isFile()) fail(`Registro nao e arquivo: ${target}`);
290
- if (!info || readFileSync(absolute(target), 'utf8') !== content) {
325
+ const current = info ? readFileSync(absolute(target)) : null;
326
+ captureInput(target, current);
327
+ if (!current || current.toString('utf8') !== content) {
291
328
  registries.set(target, content);
292
329
  if (force && info) backup.add(target);
293
330
  }
@@ -304,10 +341,10 @@ export function synchronize({ root = projectRoot, check = false, force = false,
304
341
  if (info && !info.isFile()) fail(`Registro nao e arquivo: ${manifest}`);
305
342
  if (info && !ownedPaths.has(manifest)) { remember(manifest); continue; }
306
343
  const current = info ? readFileSync(absolute(manifest)) : null;
344
+ captureInput(manifest, current);
307
345
  const priorHashes = [previousOutputs[manifest], installed?.files?.[manifest]];
308
346
  const unchanged = current === null || priorHashes.includes(createHash('sha256').update(current).digest('hex'));
309
- if (!unchanged && !force) conflicts.add(manifest);
310
- if (!unchanged && force) backup.add(manifest);
347
+ if (!unchanged) { remember(manifest, 'edited-generated-file'); continue; }
311
348
  const nextHash = createHash('sha256').update(content).digest('hex');
312
349
  if (installed?.files?.[manifest] !== nextHash) discoveryOutputs[manifest] = nextHash;
313
350
  if (!info || readFileSync(absolute(manifest), 'utf8') !== content) {
@@ -332,6 +369,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
332
369
  if (info && !info.isFile()) fail(`.gitignore nao e arquivo: ${target}`);
333
370
  let merged;
334
371
  const current = info ? readFileSync(absolute(target), 'utf8') : '';
372
+ captureInput(target, info ? Buffer.from(current) : null);
335
373
  if (target !== '.pbai4devs/.gitignore' && info && !ownedPaths.has(target) && !current.split(/\r?\n/).some(line => [ignoreBegin, ignoreEnd].includes(line))) { remember(target); continue; }
336
374
  try {
337
375
  let patterns = readFileSync(absolute(template), 'utf8');
@@ -355,7 +393,11 @@ export function synchronize({ root = projectRoot, check = false, force = false,
355
393
  ancestor = posix.dirname(ancestor);
356
394
  }
357
395
  const info = stat(safe(target));
358
- if (info && (!info.isFile() || !referenceFile(target))) {
396
+ const targetBytes = info?.isFile() ? readFileSync(absolute(target)) : null;
397
+ captureInput(target, targetBytes);
398
+ const reference = targetBytes && referenceFile(target, targetBytes);
399
+ const mixed = info?.isFile() && /\.(md|mdc|toml)$/i.test(target) && (priorBlocks[target] || !reference);
400
+ if (info && !mixed && (!info.isFile() || !reference)) {
359
401
  scan(target);
360
402
  if (force) planRemove(target, true); else conflicts.add(target);
361
403
  }
@@ -364,7 +406,11 @@ export function synchronize({ root = projectRoot, check = false, force = false,
364
406
  if (/[{}]/.test(source)) fail(`Caminho incompativel com inclusao Gemini: ${source}`);
365
407
  const command = built.catalog.commands.find(entry => entry.path === source);
366
408
  if (!command) fail(`Comando Gemini exige COMMAND.md: ${source}`);
367
- const content = `${tomlMarker}\ndescription = ${JSON.stringify(command.description)}\nprompt = ${JSON.stringify(`@{${source}}\n\n{{args}}`)}\n`;
409
+ const current = mixed ? targetBytes : null;
410
+ const merged = mixed ? mergeTomlContent(current, `@{${source}}`, { expectedHash: priorBlocks[target] }) : null;
411
+ const content = merged ? merged.text : `${tomlMarker}\ndescription = ${JSON.stringify(command.description)}\nprompt = ${JSON.stringify(`@{${source}}\n\n{{args}}`)}\n`;
412
+ if (mixed) { captureInput(target, current); managedBlocks[target] = merged.sha256; remember(target, 'existing-file-extended'); }
413
+ else rememberGenerated(target, content);
368
414
  expected.set(target, content);
369
415
  if (!info?.isFile() || readFileSync(absolute(target), 'utf8') !== content) changed.push(target);
370
416
  continue;
@@ -380,7 +426,11 @@ export function synchronize({ root = projectRoot, check = false, force = false,
380
426
  if (!title) fail(`Titulo ausente: ${source}`);
381
427
  const ruleInclusion = /^\.(?:agents|agent)\/rules\//.test(target);
382
428
  const inclusion = ruleInclusion ? `@[origem](${link(target, source)})` : `Leia e siga [origem](${link(target, source)}).`;
383
- const content = `${frontmatter}\n${title}\n\n${marker}\n${inclusion}\n`;
429
+ const current = mixed ? targetBytes : null;
430
+ const merged = mixed ? mergeManagedContent(current, inclusion, { expectedHash: priorBlocks[target] }) : null;
431
+ const content = merged ? merged.text : `${frontmatter}\n${title}\n\n${marker}\n${inclusion}\n`;
432
+ if (mixed) { captureInput(target, current); managedBlocks[target] = merged.sha256; remember(target, 'existing-file-extended'); }
433
+ else rememberGenerated(target, content);
384
434
  expected.set(target, content);
385
435
  if (!info?.isFile() || readFileSync(absolute(target), 'utf8') !== content) changed.push(target);
386
436
  }
@@ -394,8 +444,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
394
444
  const files = scan(scope);
395
445
  if (!info.isDirectory()) { if (claimed(scope)) { if (force) planRemove(scope, true); else conflicts.add(scope); } else remember(scope); continue; }
396
446
  for (const file of files) if (!expected.has(file)) {
447
+ if (priorBlocks[file]) continue;
397
448
  if (referenceFile(file)) planRemove(file);
398
- else if (claimed(file)) { if (force) planRemove(file, true); else conflicts.add(file); }
449
+ else if (claimed(file)) remember(file, 'edited-generated-file');
399
450
  else remember(file);
400
451
  }
401
452
  function directories(value) {
@@ -410,40 +461,77 @@ export function synchronize({ root = projectRoot, check = false, force = false,
410
461
  directories(scope);
411
462
  }
412
463
  for (const file of scan(agent, { markdownOnly: true, prune: true })) {
413
- if (protectedPath(file) || expected.has(file) || !read(absolute(file)).includes(marker)) continue;
464
+ if (protectedPath(file) || expected.has(file) || priorBlocks[file] || !read(absolute(file)).includes(marker)) continue;
414
465
  if (referenceFile(file)) planRemove(file);
415
- else if (force) planRemove(file, true);
416
- else conflicts.add(file);
466
+ else remember(file, 'edited-generated-file');
417
467
  }
418
468
  }
419
469
  for (const { target } of missing) cleanScope(target);
420
470
  const rootEntries = new Map();
471
+ const nextEntries = generateEntrypoints ? {} : { ...previousEntries.entries };
421
472
  if (generateEntrypoints) {
422
473
  const previous = previousEntries;
423
- const next = { schemaVersion: 1, entries: {} };
424
- if (Object.keys(discoveryOutputs).length) next.outputs = discoveryOutputs;
425
- for (const name of resolveAdapters(installed?.clients).entrypoints) {
426
- const template = `.pbai4devs/templates/entrypoints/${name}`;
474
+ for (const canonical of resolveAdapters(installed?.clients).entrypoints) {
475
+ const name = resolveEntrypoint(root, canonical);
476
+ const template = `.pbai4devs/templates/entrypoints/${canonical}`;
427
477
  if (!stat(safe(template))?.isFile()) continue;
428
478
  if (preserveBoundary(name)) continue;
429
479
  const info = stat(safe(name));
430
480
  if (preserveNativeType(name, info, 'isFile')) continue;
431
481
  const current = info?.isFile() ? readFileSync(absolute(name)) : null;
482
+ captureInput(name, current);
432
483
  const previousHash = previous.entries[name] ?? installed?.files?.[name];
433
- if (info && (!current || createHash('sha256').update(current).digest('hex') !== previousHash)) { remember(name); continue; }
484
+ const previousBlockHash = priorBlocks[name] ?? Object.entries(priorBlocks).find(([path]) => canonicalEntrypoint(path) === canonical)?.[1];
485
+ if (current && (previousBlockHash || createHash('sha256').update(current).digest('hex') !== previousHash)) {
486
+ const merged = mergeManagedContent(current, entrypointReference(root, canonical), { expectedHash: previousBlockHash });
487
+ captureInput(name, current);
488
+ managedBlocks[name] = merged.sha256;
489
+ rootEntries.set(name, merged.text);
490
+ remember(name, 'existing-file-extended');
491
+ continue;
492
+ }
434
493
  if (current && createHash('sha256').update(current).digest('hex') === installed?.files?.[name]) {
435
- rootEntries.set(name, current.toString('utf8'));
494
+ const content = renderEntrypointImports(root, current.toString('utf8'));
495
+ rootEntries.set(name, content);
496
+ const nextHash = createHash('sha256').update(content).digest('hex');
497
+ if (nextHash !== installed.files[name]) nextEntries[name] = nextHash;
436
498
  continue;
437
499
  }
438
- const content = readFileSync(absolute(template), 'utf8');
500
+ const content = renderEntrypointImports(root, readFileSync(absolute(template), 'utf8'));
439
501
  const nextHash = createHash('sha256').update(content).digest('hex');
440
- next.entries[name] = nextHash;
502
+ nextEntries[name] = nextHash;
441
503
  rootEntries.set(name, content);
442
504
  }
443
- const content = JSON.stringify(next, null, 2) + '\n';
444
- if ((Object.keys(next.entries).length || Object.keys(discoveryOutputs).length || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== content)) registries.set(entryRegistry, content);
445
505
  }
446
- const ownedOutputs = sorted([...targets, ...built.outputs.keys(), '.pbai4devs/espelhos.conf', ...rootEntries.keys(), ...(generateEntrypoints && (stat(safe(entryRegistry)) || registries.has(entryRegistry)) ? [entryRegistry] : []),
506
+ for (const [target, digest] of Object.entries(priorBlocks)) {
507
+ if (!(validResourcePath(target) || canonicalEntrypoint(target))) continue;
508
+ if (managedBlocks[target]) continue;
509
+ if (canonicalEntrypoint(target) && Object.keys(managedBlocks).some(path => canonicalEntrypoint(path) === canonicalEntrypoint(target))) { removedBlocks.add(target); continue; }
510
+ if (!generateEntrypoints && canonicalEntrypoint(target)) { managedBlocks[target] = digest; continue; }
511
+ if (generatedFiles[target] || nextEntries[target]) { removedBlocks.add(target); continue; }
512
+ if (expected.has(target) || rootEntries.has(target) || preserveBoundary(target)) continue;
513
+ const info = stat(safe(target));
514
+ if (!info) continue;
515
+ if (!info.isFile()) fail(`Tipo alterado em caminho gerenciado: ${target}`);
516
+ const current = readFileSync(absolute(target));
517
+ const content = target.endsWith('.toml') ? stripTomlContent(current, digest) : stripManagedContent(current, digest);
518
+ captureInput(target, current);
519
+ expected.set(target, content);
520
+ if (readFileSync(absolute(target), 'utf8') !== content) changed.push(target);
521
+ retiredBlocks.add(target);
522
+ removedBlocks.add(target);
523
+ remember(target, 'existing-file-extended');
524
+ }
525
+ const next = { schemaVersion: 1, entries: nextEntries };
526
+ if (Object.keys(discoveryOutputs).length) next.outputs = discoveryOutputs;
527
+ if (Object.keys(generatedFiles).length) next.files = generatedFiles;
528
+ const derivedBlocks = Object.fromEntries(Object.entries(managedBlocks).filter(([path, hash]) => installed?.blocks?.[path] !== hash || Object.hasOwn(previousEntries.blocks ?? {}, path)));
529
+ if (Object.keys(derivedBlocks).length) next.blocks = derivedBlocks;
530
+ for (const path of Object.keys(managedBlocks)) removedBlocks.delete(path);
531
+ if (removedBlocks.size) next.removedBlocks = sorted(removedBlocks);
532
+ const registryContent = JSON.stringify(next, null, 2) + '\n';
533
+ if (recordDerivedState && (Object.keys(nextEntries).length || Object.keys(discoveryOutputs).length || Object.keys(generatedFiles).length || Object.keys(derivedBlocks).length || removedBlocks.size || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== registryContent)) registries.set(entryRegistry, registryContent);
534
+ const ownedOutputs = sorted([...targets, ...built.outputs.keys(), '.pbai4devs/espelhos.conf', ...rootEntries.keys(), ...(recordDerivedState && (stat(safe(entryRegistry)) || registries.has(entryRegistry)) ? [entryRegistry] : []),
447
535
  ...[...discoveries].flatMap(agent => ['skills', 'rules', 'agents'].map(kind => `${agent}/${kind}.json`)),
448
536
  ...[...ignoreTemplates.keys()].filter(target => {
449
537
  if (target === '.pbai4devs/.gitignore') return false;
@@ -452,7 +540,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
452
540
  const start = content.indexOf(ignoreBegin), finish = content.indexOf(ignoreEnd);
453
541
  return start >= 0 && finish > start && !(content.slice(0, start) + content.slice(finish + ignoreEnd.length)).trim();
454
542
  }),
455
- ]).filter(path => ![...preserved.keys()].some(value => path === value || path.startsWith(`${value}/`)));
543
+ ]).filter(path => !managedBlocks[path] && !retiredBlocks.has(path) && ![...preserved.keys()].some(value => path === value || path.startsWith(`${value}/`)));
456
544
  const localPlan = localIgnores ? planLocalIgnore(root, { outputs: ownedOutputs }) : null;
457
545
  const localStatus = localPlan ? checkLocalIgnore(localPlan) : { changed: 0, missing: [], trackedOutputs: [] };
458
546
  const removals = topPaths(remove);
@@ -473,6 +561,13 @@ export function synchronize({ root = projectRoot, check = false, force = false,
473
561
  }
474
562
  const transactionRoots = topPaths([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
475
563
  const originals = new Map(transactionRoots.map(value => [value, capture(value)]));
564
+ for (const [path, bytes] of mergedInputs) {
565
+ const boundary = transactionRoots.find(root => path === root || path.startsWith(root + '/'));
566
+ if (!boundary) continue;
567
+ let snapshot = originals.get(boundary);
568
+ for (const segment of path.slice(boundary.length).split('/').filter(Boolean)) snapshot = snapshot?.children?.[segment];
569
+ if (bytes === null ? snapshot != null : !snapshot?.data?.equals(bytes)) fail(`Arquivo mudou apos planejar: ${path}`);
570
+ }
476
571
  const currentHashes = new Map([...originals].map(([value, content]) => [value, fingerprint(content)]));
477
572
  const originalDirectories = new Set();
478
573
  for (const value of transactionRoots) {
@@ -486,7 +581,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
486
581
  const dirty = Object.values(summary).some(Boolean);
487
582
  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.`);
488
583
  const preservedPaths = sorted(preserved.keys()).map(path => preserved.get(path));
489
- if (check) return { ...summary, ownedOutputs, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
584
+ if (check) return { ...summary, ownedOutputs, managedBlocks, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
490
585
  if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
491
586
  if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
492
587
  if (localPlan) assertLocalIgnoreUnchanged(localPlan);
@@ -592,7 +687,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
592
687
  if (failures.length) throw new AggregateError([error, ...failures], `${error.message}; rollback incompleto: ${failures.map(failure => failure.message).join('; ')}`);
593
688
  throw error;
594
689
  }
595
- return { ...summary, ownedOutputs, preserved: preservedPaths, exitCode: 0, ...(backupRoot ? { backupRoot } : {}) };
690
+ return { ...summary, ownedOutputs, managedBlocks, preserved: preservedPaths, exitCode: 0, ...(backupRoot ? { backupRoot } : {}) };
596
691
  }
597
692
 
598
693
  export function main(args = process.argv.slice(2)) {
@@ -39,7 +39,7 @@ function snapshot(root) {
39
39
  visit(); return result;
40
40
  }
41
41
 
42
- test('coexiste com aliases nativos, configuração e colisão humana sem alterar os destinos', t => {
42
+ test('coexiste com aliases nativos e complementa colisões sem perder conteúdo humano', t => {
43
43
  const root = fixture(t);
44
44
  put(root, '.claude/skills/existing/SKILL.md', '# Human skill\n');
45
45
  put(root, '.claude/agents/revisor.md', '# Human reviewer\n');
@@ -48,7 +48,12 @@ test('coexiste com aliases nativos, configuração e colisão humana sem alterar
48
48
  put(root, 'external/config.json', '{"human":true}\n');
49
49
  if (!link(t, root, '../.claude/skills', '.codex/skills', 'dir') || !link(t, root, 'AGENTS.md', 'CLAUDE.md') || !link(t, root, '../external/config.json', '.claude/settings.local.json')) return;
50
50
  const original = snapshot(root), result = run(root, { force: true });
51
- for (const [path, before] of Object.entries(original)) assert.deepEqual(snapshot(root)[path], before, path);
51
+ for (const [path, before] of Object.entries(original)) {
52
+ if (['AGENTS.md', '.claude/agents/revisor.md'].includes(path)) {
53
+ assert.ok(fs.readFileSync(join(root, path), 'utf8').startsWith(path === 'AGENTS.md' ? '# Human instructions\n' : '# Human reviewer\n'));
54
+ assert.match(fs.readFileSync(join(root, path), 'utf8'), /<!-- BEGIN AGENTDEVKIT -->/);
55
+ } else assert.deepEqual(snapshot(root)[path], before, path);
56
+ }
52
57
  for (const path of ['.codex/skills', 'CLAUDE.md', '.claude/agents/revisor.md', '.claude/.gitignore', '.claude/settings.local.json']) assert.ok(result.preserved.some(item => item.path === path), path);
53
58
  assert.ok(result.ownedOutputs.includes('.agents/skills/demo/SKILL.md'));
54
59
  assert.ok(result.ownedOutputs.every(path => !path.startsWith('.codex/skills/') && path !== '.claude/agents/revisor.md' && path !== 'CLAUDE.md'));
@@ -98,25 +103,24 @@ test('link em caminho registrado continua sendo erro antes de qualquer escrita',
98
103
  }
99
104
  });
100
105
 
101
- test('manifesto standalone tem propriedade explícita e alterações humanas exigem reparo autorizado', t => {
106
+ test('manifesto standalone editado pelo usuário passa a ser preservado', t => {
102
107
  const root = fixture(t); run(root);
103
108
  const path = '.agents/skills.json', original = fs.readFileSync(join(root, path), 'utf8');
104
109
  const derived = JSON.parse(fs.readFileSync(join(root, '.pbai4devs/estado/derived.json')));
105
110
  assert.equal(derived.outputs[path], hash(original));
106
111
  put(root, path, '{"entries":[{"path":".agents/skills/custom"}]}\n');
107
- const before = snapshot(root);
108
- assert.throws(() => run(root), /--force/);
109
- assert.deepEqual(snapshot(root), before);
110
112
  const result = run(root, { force: true });
111
- assert.equal(fs.readFileSync(join(root, path), 'utf8'), original);
112
- assert.equal(fs.readFileSync(join(result.backupRoot, path), 'utf8'), '{"entries":[{"path":".agents/skills/custom"}]}\n');
113
+ assert.equal(fs.readFileSync(join(root, path), 'utf8'), '{"entries":[{"path":".agents/skills/custom"}]}\n');
114
+ assert.ok(result.preserved.some(item => item.path === path));
115
+ assert.ok(!result.ownedOutputs.includes(path));
116
+ assert.equal(run(root, { check: true }).exitCode, 0);
113
117
  });
114
118
 
115
- test('manifesto nativo omite colisões preservadas e inclui somente apontamentos ativos', t => {
119
+ test('manifesto nativo inclui recursos humanos complementados com apontamento', t => {
116
120
  const root = fixture(t), collision = '.agents/skills/demo/SKILL.md';
117
121
  put(root, collision, '# Human demo\n');
118
122
  const result = run(root);
119
- assert.deepEqual(JSON.parse(fs.readFileSync(join(root, '.agents/skills.json'))).entries, []);
123
+ assert.deepEqual(JSON.parse(fs.readFileSync(join(root, '.agents/skills.json'))).entries, [{ path: '.agents/skills/demo' }]);
120
124
  assert.ok(result.preserved.some(item => item.path === collision));
121
125
  assert.ok(!result.ownedOutputs.includes(collision));
122
126
  assert.equal(run(root, { check: true }).exitCode, 0);
@@ -166,7 +170,12 @@ test('variantes humanas de entradas da raiz são preservadas sem criar duplicata
166
170
  const root = fixture(t);
167
171
  for (const path of ['Readme.md', 'agents.md', 'claude.md', 'gemini.md']) put(root, path, `# Human ${path}\n`);
168
172
  const before = snapshot(root), result = run(root, { force: true });
169
- for (const [path, content] of Object.entries(before)) assert.deepEqual(snapshot(root)[path], content, path);
173
+ for (const [path, content] of Object.entries(before)) {
174
+ if (['agents.md', 'claude.md', 'gemini.md'].includes(path)) {
175
+ assert.ok(fs.readFileSync(join(root, path), 'utf8').startsWith(`# Human ${path}\n`));
176
+ assert.match(fs.readFileSync(join(root, path), 'utf8'), /<!-- BEGIN AGENTDEVKIT -->/);
177
+ } else assert.deepEqual(snapshot(root)[path], content, path);
178
+ }
170
179
  for (const [canonical, alias] of [['README.md', 'Readme.md'], ['AGENTS.md', 'agents.md'], ['CLAUDE.md', 'claude.md'], ['GEMINI.md', 'gemini.md']]) {
171
180
  assert.deepEqual(nativePathBoundary(root, canonical, [], { preserveRootAliases: true }), { path: alias, reason: 'case-variant' });
172
181
  if (canonical !== 'README.md') assert.ok(result.preserved.some(item => item.path === alias));
@@ -178,13 +187,14 @@ test('variantes humanas de entradas da raiz são preservadas sem criar duplicata
178
187
  assert.deepEqual(snapshot(root), stable);
179
188
  });
180
189
 
181
- test('variante de caixa de entrada registrada não esconde drift de propriedade', t => {
190
+ test('variante única de entrada registrada conserva conteúdo como arquivo misto', t => {
182
191
  for (const owned of ['AGENTS.md', 'agents.md']) {
183
192
  const root = fixture(t); put(root, 'agents.md', '# Renamed human entry\n');
184
193
  assert.throws(() => nativePathBoundary(root, 'AGENTS.md', [owned], { preserveRootAliases: true }), /maiusculas\/minusculas/);
185
194
  put(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, files: { [owned]: 'a'.repeat(64) }, blocks: {} }));
186
- const before = snapshot(root);
187
- assert.throws(() => run(root, { force: true }), /maiusculas\/minusculas/);
188
- assert.deepEqual(snapshot(root), before);
195
+ run(root, { force: true });
196
+ assert.ok(fs.readFileSync(join(root, 'agents.md'), 'utf8').startsWith('# Renamed human entry\n'));
197
+ assert.ok(!fs.readdirSync(root).includes('AGENTS.md'));
198
+ assert.equal(run(root, { check: true }).exitCode, 0);
189
199
  }
190
200
  });
@@ -47,8 +47,13 @@ test('texto humano com formato de referência nunca comprova propriedade', t =>
47
47
  const result = run(root);
48
48
  assert.ok(result.preserved.some(item => item.path === path));
49
49
  assert.ok(!result.ownedOutputs.includes(path));
50
- assert.equal(fs.readFileSync(join(root, path), 'utf8'), human);
51
- assert.equal(snapshot(root)[path], before[path]);
50
+ if (path === generated) {
51
+ assert.ok(fs.readFileSync(join(root, path), 'utf8').startsWith(human));
52
+ assert.match(fs.readFileSync(join(root, path), 'utf8'), /<!-- BEGIN AGENTDEVKIT -->/);
53
+ } else {
54
+ assert.equal(fs.readFileSync(join(root, path), 'utf8'), human);
55
+ assert.equal(snapshot(root)[path], before[path]);
56
+ }
52
57
  assert.equal(run(root, { check: true }).exitCode, 0);
53
58
  const stable = snapshot(root);
54
59
  run(root, { force: true });
@@ -68,6 +68,92 @@ test('check identifica negação humana e outputs rastreados sem alterar o índi
68
68
  assert.equal(git(root, ['ls-files', 'generated.md']).trim(), 'generated.md');
69
69
  });
70
70
 
71
+ test('negação genérica de cache preserva regras humanas e protege caches de clientes', t => {
72
+ const root = fixture(t); git(root, ['init', '-q']);
73
+ git(root, ['config', 'core.ignorecase', 'false']);
74
+ const original = '# Regras do projeto\n!*.[Cc]ache/\n';
75
+ put(root, '.gitignore', original); git(root, ['add', '.gitignore']);
76
+ const index = git(root, ['ls-files', '--stage', '-z']);
77
+ const plan = planLocalIgnore(root);
78
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), original);
79
+ assert.equal(existsSync(join(root, '.cursor')), false);
80
+ applyLocalIgnore(plan);
81
+ const updated = readFileSync(join(root, '.gitignore'), 'utf8');
82
+ assert.ok(updated.startsWith(original));
83
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
84
+ assert.equal(existsSync(join(root, '.cursor')), false);
85
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
86
+ put(root, '.cursor/.CACHE/private.json', 'local');
87
+ put(root, '.cursor/.cache/private.json', 'local');
88
+ put(root, '.cursor/human.md', 'human');
89
+ assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.CACHE/private.json']).includes('.cursor/.CACHE/private.json'));
90
+ assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.cache/private.json']).includes('.cursor/.cache/private.json'));
91
+ const visible = spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.cursor/human.md']);
92
+ assert.equal(visible.status, 1);
93
+ applyLocalIgnore(planLocalIgnore(root));
94
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), updated);
95
+ put(root, '.gitignore', updated + '# edição humana posterior\n');
96
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
97
+ });
98
+
99
+ test('bloco privado modificado é preservado e recusa atualização', t => {
100
+ const root = fixture(t); git(root, ['init', '-q']);
101
+ put(root, '.gitignore', '!*.[Cc]ache/\n');
102
+ applyLocalIgnore(planLocalIgnore(root));
103
+ const path = join(root, '.gitignore');
104
+ const changed = readFileSync(path, 'utf8').replace('# Estado local', '# Estado alterado');
105
+ // Alter a concrete managed rule, even if the template has no heading here.
106
+ const edited = changed.replace('/.cursor/', '/.cursor-alterado/');
107
+ assert.notEqual(edited, readFileSync(path, 'utf8'));
108
+ writeFileSync(path, edited);
109
+ assert.throws(() => planLocalIgnore(root), /Bloco privado AgentDevKit alterado/);
110
+ assert.equal(readFileSync(path, 'utf8'), edited);
111
+ });
112
+
113
+ test('negação com caixa mista não expõe cache em Git sensível a maiúsculas', t => {
114
+ const root = fixture(t); git(root, ['init', '-q']);
115
+ git(root, ['config', 'core.ignorecase', 'false']);
116
+ put(root, '.gitignore', '!*.CaChE/\n');
117
+ put(root, '.cursor/.CaChE/private.json', 'local');
118
+ applyLocalIgnore(planLocalIgnore(root));
119
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
120
+ assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.CaChE/private.json']).includes('.cursor/.CaChE/private.json'));
121
+ });
122
+
123
+ test('negações literais e com caixa mista não expõem estado privado', t => {
124
+ const root = fixture(t); git(root, ['init', '-q']);
125
+ git(root, ['config', 'core.ignorecase', 'false']);
126
+ put(root, '.gitignore', '!*.LoCaL/\n!/.cursor/.env.production\n');
127
+ for (const path of ['.cursor/.LoCaL/private.json', '.cursor/.env.production']) put(root, path, 'privado');
128
+ applyLocalIgnore(planLocalIgnore(root));
129
+ for (const path of ['.cursor/.LoCaL/private.json', '.cursor/.env.production']) assert.ok(git(root, ['check-ignore', '--no-index', path]).includes(path));
130
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
131
+ });
132
+
133
+ test('negação na primeira linha com BOM preserva bytes e protege segredos', t => {
134
+ const root = fixture(t); git(root, ['init', '-q']);
135
+ const original = '\uFEFF!/.cursor/.env.production\r\n';
136
+ put(root, '.gitignore', original); put(root, '.cursor/.env.production', 'privado');
137
+ applyLocalIgnore(planLocalIgnore(root));
138
+ assert.ok(readFileSync(join(root, '.gitignore'), 'utf8').startsWith(original));
139
+ assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.env.production']).includes('.cursor/.env.production'));
140
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
141
+ });
142
+
143
+ test('negação humana acrescentada após o bloco recebe proteção na próxima sincronização', t => {
144
+ const root = fixture(t); git(root, ['init', '-q']);
145
+ put(root, '.gitignore', '!*.cache/\n'); applyLocalIgnore(planLocalIgnore(root));
146
+ const path = join(root, '.gitignore');
147
+ writeFileSync(path, readFileSync(path, 'utf8') + '!/.cursor/.env.production\n');
148
+ put(root, '.cursor/.env.production', 'privado');
149
+ const plan = planLocalIgnore(root);
150
+ assert.ok(plan.edits.some(edit => edit.path === path));
151
+ applyLocalIgnore(plan);
152
+ assert.match(readFileSync(path, 'utf8'), /!\*\.cache\/\n!\/\.cursor\/\.env\.production\n/);
153
+ assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.env.production']).includes('.cursor/.env.production'));
154
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
155
+ });
156
+
71
157
  test('planejamento é somente leitura e aplicação detecta mudança concorrente', t => {
72
158
  const root = fixture(t); put(root, '.gitignore', 'antes\r\n');
73
159
  const plan = planLocalIgnore(root, { outputs: ['generated.md'] });
@@ -119,15 +205,15 @@ test('desindexação não descarta edição humana staged com workingtree restau
119
205
  assert.equal(readFileSync(join(root, 'generated.md'), 'utf8'), 'gerado\n');
120
206
  });
121
207
 
122
- test('raízes dedicadas são locais, enquanto configurações de GitHub e VS Code são fontes', t => {
208
+ test('configurações humanas dos clientes continuam versionáveis', t => {
123
209
  const root = fixture(t); git(root, ['init', '-q']);
124
210
  applyLocalIgnore(planLocalIgnore(root));
125
211
  put(root, '.codex/config.toml', 'local'); put(root, '.claude/settings.json', 'local');
126
212
  put(root, '.github/settings.json', 'fonte'); put(root, '.vscode/settings.json', 'fonte');
127
213
  git(root, ['add', '--all']);
128
214
  const tracked = git(root, ['ls-files', '-z']).split('\0');
129
- assert.equal(tracked.includes('.codex/config.toml'), false);
130
- assert.equal(tracked.includes('.claude/settings.json'), false);
215
+ assert.equal(tracked.includes('.codex/config.toml'), true);
216
+ assert.equal(tracked.includes('.claude/settings.json'), true);
131
217
  assert.ok(tracked.includes('.github/settings.json')); assert.ok(tracked.includes('.vscode/settings.json'));
132
218
  });
133
219
 
@@ -250,7 +336,7 @@ test('negação humana é detectada antes de aplicar novas exclusões e permanec
250
336
  put(root, '.gitignore', '!/AGENTS.md\n');
251
337
  const before = readFileSync(join(root, '.git/info/exclude'), 'utf8');
252
338
  const plan = planLocalIgnore(root, { outputs: ['AGENTS.md'] });
253
- assert.equal(plan.edits.length, 1);
339
+ assert.ok(plan.edits.some(edit => edit.path === join(root, '.git/info/exclude')));
254
340
  assert.deepEqual(checkLocalIgnore(plan).missing, ['AGENTS.md']);
255
341
  assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
256
342
  assert.equal(readFileSync(join(root, '.git/info/exclude'), 'utf8'), before);