@pathbit/agentdevkit 0.0.18 → 0.0.20

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.
@@ -332,26 +332,6 @@ function ambiguousExceptions(root, location, clientRoots, artifacts) {
332
332
  return conflicts;
333
333
  }
334
334
 
335
- function foreignHiddenClients(plan) {
336
- if (!plan.location) return [];
337
- const content = localRead(plan.location.exclude) || '', ranges = [];
338
- let start = null;
339
- content.split(/\r?\n/).forEach((line, index) => {
340
- const begin = line.match(/^# BEGIN AGENTDEVKIT LOCAL ([a-f0-9]{16})$/);
341
- const end = line.match(/^# END AGENTDEVKIT LOCAL ([a-f0-9]{16})$/);
342
- if (begin) start = { id: begin[1], line: index + 1 };
343
- else if (end && start?.id === end[1]) { ranges.push({ ...start, end: index + 1 }); start = null; }
344
- });
345
- 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`);
346
- if (!probes.length) return [];
347
- const { paths, matches } = ignoreMatches(plan.location, probes);
348
- return paths.flatMap((path, index) => {
349
- const match = matches.get(path);
350
- if (!match || match.pattern.startsWith('!') || resolve(plan.location.top, match.source) !== plan.location.exclude) return [];
351
- return ranges.some(range => `# BEGIN AGENTDEVKIT LOCAL ${range.id}` !== plan.first && match.line >= range.line && match.line <= range.end) ? [probes[index]] : [];
352
- });
353
- }
354
-
355
335
  function localIgnoreSource(location, source) {
356
336
  const path = resolve(location.top, source);
357
337
  if (path === location.exclude) return true;
@@ -370,6 +350,21 @@ function ignoreMatches(location, probes) {
370
350
  return { paths, matches };
371
351
  }
372
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
+
373
368
  function localRoot(value) {
374
369
  const missing = []; let parent = resolve(value);
375
370
  while (!localStat(parent)) { missing.unshift(parent.slice(dirname(parent).length + 1)); parent = dirname(parent); }
@@ -387,15 +382,17 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
387
382
  const artifacts = artifactPolicy(templateFile);
388
383
  if (template.split('{{OUTPUTS}}').length !== 2) throw new Error('local.ignore exige um unico {{OUTPUTS}}.');
389
384
  if (template.split('{{ROOTS}}').length > 2) throw new Error('local.ignore permite um unico {{ROOTS}}.');
390
- // A client directory can also contain human instructions and settings. Only
391
- // whole files owned by the kit belong in the local output exclusion list.
392
- 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.
393
387
  const localPolicy = join(templateRoot || root, '.pbai4devs/templates/clients/ADAPTERS.json');
394
388
  const policy = JSON.parse(readFileSync(checkedLocalPath(localStat(localPolicy) ? localPolicy : fileURLToPath(new URL('../../templates/clients/ADAPTERS.json', import.meta.url))), 'utf8'));
395
389
  const clientRoots = [...new Set(policy.adapters.flatMap(adapter => [adapter.root, ...(adapter.extraRoots || [])]))];
396
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];
397
392
  const privateClients = privateClientPolicy(templateFile, clientRoots);
398
- 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();
399
396
  const info = gitCommand(resolved.parent, ['rev-parse', '--show-toplevel', '--git-path', 'info/exclude']);
400
397
  let location = null;
401
398
  if (info.status === 0) {
@@ -412,6 +409,19 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
412
409
  throw new Error(`Nao foi possivel localizar Git: ${info.error?.message || info.stderr}`);
413
410
  }
414
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
+ });
415
425
  const first = `# BEGIN AGENTDEVKIT LOCAL ${id}`, last = `# END AGENTDEVKIT LOCAL ${id}`;
416
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'))
417
427
  + '\n# Estado privado dos clientes\n' + privateClients.rules.join('\n') + '\n'
@@ -424,10 +434,9 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
424
434
  const target = location?.exclude;
425
435
  const before = target ? localRead(target) : null, after = target ? localBlock(before, first, last, body) : null;
426
436
  const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
427
- const probes = [...paths, ...rootProbes(root, roots)];
437
+ const probes = [...paths, ...rootProbes(root, roots.filter(client => !optedRoots.includes(client)))];
428
438
  const existingPrivate = privateClients.probes.filter(path => localStat(join(root, path.split('/')[0]))?.isDirectory() && safeIgnoreProbe(root, path));
429
439
  const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...existingPrivate, ...(location ? [...visibleArtifactProbes(root, location, artifacts), ...visiblePrivateProbes(root, location, clientRoots)] : [])])];
430
- const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
431
440
  if (location && !before?.includes(first)) {
432
441
  const ignored = ignoreMatches(location, [...probes, ...artifactPaths].filter(path => safeIgnoreProbe(root, path)));
433
442
  if (ignored.paths.every(path => { const match = ignored.matches.get(path); return match && !match.pattern.startsWith('!') && localIgnoreSource(location, match.source); })) edits.length = 0;
@@ -436,14 +445,16 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
436
445
  const tracked = location ? gitCommand(location.top, ['ls-files', '-z']).stdout.split('\0').filter(path => {
437
446
  if (!path.startsWith(prefix)) return false;
438
447
  const local = path.slice(prefix.length);
439
- 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);
440
451
  }).map(path => path.slice(prefix.length)) : [];
441
- 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 };
442
453
  }
443
454
 
444
455
  export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
445
456
  const missing = [];
446
- 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 || []));
447
458
  const replacements = replacedBlocks.map(item => {
448
459
  const path = canonicalExisting(checkedLocalPath(resolve(item.path))), inside = relative(localRoot(plan.root).root, path);
449
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.');
@@ -456,9 +467,9 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
456
467
  return { ...item, path };
457
468
  });
458
469
  if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
459
- 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))));
460
471
  const privateProbes = (plan.privateProbes || []).filter(path => localStat(join(plan.root, path.split('/')[0]))?.isDirectory() && safeIgnoreProbe(plan.root, path));
461
- 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,
462
473
  ...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : []),
463
474
  ...visiblePrivateProbes(plan.root, plan.location, plan.clientRoots || [])])].filter(path => safeIgnoreProbe(plan.root, path));
464
475
  const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
@@ -481,12 +492,13 @@ export function assertLocalIgnoreUnchanged(plan) {
481
492
  }
482
493
  }
483
494
 
484
- function atomicLocalWrite(path, content, mode, published) {
495
+ function atomicLocalWrite(path, content, mode, published, beforePublish) {
485
496
  const temporary = `${path}.agentdevkit-${randomUUID()}`;
486
497
  try {
487
498
  writeFileSync(temporary, content, { flag: 'wx' });
488
499
  if (mode !== null && mode !== undefined) chmodSync(temporary, mode);
489
500
  const writtenMode = localMode(temporary);
501
+ beforePublish?.();
490
502
  renameSync(temporary, path);
491
503
  published?.(writtenMode);
492
504
  } finally { rmSync(temporary, { force: true }); }
@@ -500,7 +512,10 @@ export function applyLocalIgnore(plan) {
500
512
  try {
501
513
  for (const edit of plan.edits) {
502
514
  mkdirSync(dirname(edit.path), { recursive: true });
503
- 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
+ });
504
519
  }
505
520
  const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
506
521
  if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
@@ -509,7 +524,12 @@ export function applyLocalIgnore(plan) {
509
524
  for (const { edit, mode } of written.reverse()) {
510
525
  try {
511
526
  if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) continue;
512
- 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);
513
533
  } catch (failure) { rollbackErrors.push(failure); }
514
534
  }
515
535
  if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
@@ -49,6 +49,20 @@ export function validateToolConfig(name, value) {
49
49
  }
50
50
  }
51
51
  }
52
+ if (value.optionalExecutables !== undefined) {
53
+ if (download.provider !== 'npm' || !value.optionalExecutables || typeof value.optionalExecutables !== 'object' || Array.isArray(value.optionalExecutables) || !Object.keys(value.optionalExecutables).length) fail(`Mapa de executaveis opcionais invalido: ${name}`);
54
+ for (const [platform, files] of Object.entries(value.optionalExecutables)) {
55
+ if (!value.platforms.includes(platform) || !Array.isArray(files) || !files.length) fail(`Plataforma de executaveis opcionais invalida: ${name}/${platform}`);
56
+ const seen = new Set();
57
+ for (const item of files) {
58
+ if (!item || typeof item !== 'object' || Array.isArray(item) || Object.keys(item).sort().join(',') !== 'file,package'
59
+ || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(item.package || '') || seen.has(item.package)
60
+ || typeof item.file !== 'string' || !item.file || item.file.includes('\0') || item.file.includes('\\') || item.file.includes(':')
61
+ || item.file.split('/').some(part => !part || part === '.' || part === '..')) fail(`Executavel opcional deve ser relativo ao pacote: ${name}`);
62
+ seen.add(item.package);
63
+ }
64
+ }
65
+ }
52
66
  if (value.scripts?.entry !== 'SCRIPT.mjs' || Object.keys(value.scripts).length !== 1) fail(`Script invalido; use scripts.entry=SCRIPT.mjs: ${name}`);
53
67
  if (value.nodeMinimum !== undefined) fail(`nodeMinimum foi substituido pelo requisito comum Node >=24: ${name}`);
54
68
  const environmentKeys = new Set();
@@ -162,7 +176,7 @@ export function binaryInvocation(prefix, name, args = [], binaryName = name.spli
162
176
  if (typeof bin !== 'string' || !bin || bin.includes('\0')) fail(`Executavel ausente ou invalido: ${name}`);
163
177
  const binary = resolve(base, bin); const traversal = relative(base, binary);
164
178
  if (isAbsolute(traversal) || traversal === '..' || traversal.startsWith('..' + (process.platform === 'win32' ? '\\' : '/'))) fail('Executavel fora do pacote.');
165
- const resolved = relative(prefix, realpathSync(binary));
179
+ const resolved = relative(realpathSync(prefix), realpathSync(binary));
166
180
  if (isAbsolute(resolved) || resolved === '..' || resolved.startsWith('..' + (process.platform === 'win32' ? '\\' : '/'))) fail('Executavel fora da instalacao local.');
167
181
  const header = Buffer.alloc(128); const handle = openSync(binary, 'r');
168
182
  try { readSync(handle, header, 0, header.length, 0); } finally { closeSync(handle); }
@@ -203,25 +217,57 @@ function execute(prefix, entry, args, capture = false, input, environment, runti
203
217
  return result.status ?? 1;
204
218
  }
205
219
 
206
- function prepareExecutables(prefix, entry, platform) {
207
- const files = entry.executables?.[platform];
208
- if (!files || process.platform === 'win32') return;
220
+ async function prepareExecutables(prefix, entry, platform, pkg, lock, fetchOptional) {
221
+ const files = entry.executables?.[platform] || [];
222
+ const optional = entry.optionalExecutables?.[platform] || [];
223
+ if (!files.length && !optional.length) return;
209
224
  const within = (base, file) => { const child = relative(base, file); return !isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`); };
210
225
  const stage = realpathSync(prefix);
211
226
  const packageRoot = realpathSync(join(prefix, 'node_modules', entry.download.package));
212
227
  if (!within(stage, packageRoot)) fail(`Pacote fora do staging: ${entry.name}`);
213
- const targets = files.map(file => {
214
- const target = realpathSync(join(packageRoot, file));
215
- if (!within(packageRoot, target)) fail(`Executavel fora do pacote: ${entry.name}/${file}`);
228
+ const declared = optional.filter(item => Object.hasOwn(pkg.optionalDependencies || {}, item.package));
229
+ const targets = [];
230
+ const collect = (base, file, label) => {
231
+ const target = realpathSync(join(base, file));
232
+ if (!within(base, target)) fail(`Executavel fora do pacote: ${label}`);
216
233
  const info = lstatSync(target);
217
- if (!info.isFile()) fail(`Executavel nao e arquivo regular: ${entry.name}/${file}`);
218
- return { target, mode: (info.mode & 0o777) | 0o111 };
219
- });
220
- for (const { target, mode } of targets) chmodSync(target, mode);
234
+ if (!info.isFile()) fail(`Executavel nao e arquivo regular: ${label}`);
235
+ targets.push({ target, mode: (info.mode & 0o777) | 0o111 });
236
+ };
237
+ // Older releases contain all of their binaries in the main package. Newer
238
+ // releases declare a host package; inspect every legacy file that exists so
239
+ // the upstream launcher cannot prefer an unchecked file over that package.
240
+ for (const file of files) {
241
+ let exists = false;
242
+ try { lstatSync(join(packageRoot, file)); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
243
+ if (exists || !declared.length) collect(packageRoot, file, `${entry.name}/${file}`);
244
+ }
245
+ if (declared.length) {
246
+ let installed = 0;
247
+ for (const item of declared) {
248
+ const version = pkg.optionalDependencies[item.package];
249
+ if (!versionPattern.test(version || '')) fail(`Versao de executavel opcional invalida: ${item.package}`);
250
+ const directory = sourcePath(prefix, join('node_modules', item.package));
251
+ try { lstatSync(directory); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
252
+ const optionalRoot = realpathSync(directory);
253
+ if (!within(realpathSync(join(prefix, 'node_modules')), optionalRoot)) fail(`Pacote opcional fora de node_modules: ${item.package}`);
254
+ const manifest = json(join(optionalRoot, 'package.json'));
255
+ const record = lock.packages?.[`node_modules/${item.package}`];
256
+ const official = await fetchOptional(item.package, version);
257
+ const tarball = new URL(official.dist?.tarball);
258
+ if (official.name !== item.package || official.version !== version || tarball.origin !== registry.slice(0, -1)
259
+ || !decodeURIComponent(tarball.pathname).startsWith(`/${item.package}/-/`) || tarball.search || tarball.hash
260
+ || !official.dist?.integrity?.startsWith('sha512-') || manifest.name !== item.package || manifest.version !== version
261
+ || record?.version !== version || record.integrity !== official.dist.integrity || record.resolved !== official.dist.tarball) fail(`Pacote opcional instalado nao corresponde ao registro oficial: ${item.package}`);
262
+ collect(optionalRoot, item.file, `${item.package}/${item.file}`); installed++;
263
+ }
264
+ if (!installed) fail(`Executavel opcional ausente para ${entry.name}/${platform}; instale as dependencias opcionais.`);
265
+ }
266
+ if (process.platform !== 'win32') for (const { target, mode } of targets) chmodSync(target, mode);
221
267
  }
222
268
 
223
269
  export class ToolManager {
224
- constructor({ root = projectRoot, catalog, fetchRelease, install, npm = npmCli, nodeVersion = process.versions.node, platform = process.platform, arch = process.arch } = {}) {
270
+ constructor({ root = projectRoot, catalog, fetchRelease, fetchOptional, install, npm = npmCli, nodeVersion = process.versions.node, platform = process.platform, arch = process.arch } = {}) {
225
271
  this.root = resolve(root); this.home = join(this.root, '.pbai4devs/tools'); this.target = `${platform}-${arch}`;
226
272
  this.nodeVersion = nodeVersion; this.npm = npm;
227
273
  this.catalog = catalog ?? loadCatalog(this.root);
@@ -232,11 +278,19 @@ export class ToolManager {
232
278
  if (!response.ok) fail(`Registro indisponivel: HTTP ${response.status}`);
233
279
  return response.json();
234
280
  });
281
+ this.fetchOptional = fetchOptional || (async (name, version) => {
282
+ const response = await fetch(`${registry}${encodeURIComponent(name)}/${version}`, { signal: AbortSignal.timeout(20000) });
283
+ if (!response.ok) fail(`Registro indisponivel para ${name}: HTTP ${response.status}`);
284
+ return response.json();
285
+ });
235
286
  this.installPackage = install || (async (prefix, packageName, version, entry, runtime) => {
236
287
  if (entry.download.provider === 'github-release') return installNative(prefix, entry, this.target);
237
288
  const environment = toolEnvironment(entry, this.local(entry.name), runtime);
238
- const result = spawnSync(runtime.file, [this.npm(runtime, environment), 'install', '--prefix', prefix, '--cache', join(this.home, '.cache/npm'), '--registry', entry.download.registry, '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--save-exact', `${packageName}@${version}`], {
239
- cwd: prefix, encoding: 'utf8', timeout: 180000,
289
+ // npm resolves cwd but not --prefix, yielding invalid lock paths when
290
+ // ancestors are aliases such as macOS /var -> /private/var.
291
+ const npmPrefix = realpathSync(storagePath(this.root, prefix, 'directory'));
292
+ const result = spawnSync(runtime.file, [this.npm(runtime, environment), 'install', '--prefix', npmPrefix, '--cache', join(this.home, '.cache/npm'), '--registry', entry.download.registry, '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--save-exact', `${packageName}@${version}`], {
293
+ cwd: npmPrefix, encoding: 'utf8', timeout: 180000,
240
294
  env: { ...environment, npm_config_engine_strict: 'true', npm_config_update_notifier: 'false', PUPPETEER_SKIP_DOWNLOAD: 'true', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' }
241
295
  });
242
296
  if (result.error || result.status !== 0) fail(`npm: ${result.error?.message || result.stderr?.trim() || result.status}`, result.status ?? 1);
@@ -438,7 +492,7 @@ export class ToolManager {
438
492
  else {
439
493
  const pkg = json(join(stage, 'node_modules', entry.download.package, 'package.json')); const lock = json(join(stage, 'package-lock.json'));
440
494
  if (pkg.name !== entry.download.package || pkg.version !== release.version || lock.packages?.[`node_modules/${entry.download.package}`]?.integrity !== release.dist.integrity) fail('Pacote instalado nao corresponde ao registro.');
441
- prepareExecutables(stage, entry, this.target);
495
+ await prepareExecutables(stage, entry, this.target, pkg, lock, this.fetchOptional);
442
496
  }
443
497
  execute(stage, entry, entry.probe, true, entry.probeInput, toolEnvironment(entry, local, runtime), runtime);
444
498
  this.storage(name); storagePath(this.root, target, 'directory');
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, readdirSync, existsSync, copyFileSync, symlinkSync, unlinkSync, chmodSync, statSync, renameSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, readdirSync, existsSync, copyFileSync, symlinkSync, unlinkSync, chmodSync, statSync, renameSync, realpathSync } from 'node:fs';
4
4
  import { resolve, join, dirname, delimiter } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
@@ -519,6 +519,26 @@ writeFileSync(join(prefix, 'npm-proof.json'), JSON.stringify({args,path:process.
519
519
  assert.equal(JSON.parse(readFileSync(join(local, 'CURRENT.json'), 'utf8')).nodePath, process.execPath);
520
520
  });
521
521
 
522
+ test('npm recebe staging canonico quando a raiz usa alias simbolico', async t => {
523
+ const config = tool(); const { root } = fixture(t, { 'test-tool': config });
524
+ const alias = join(root, 'project-alias');
525
+ if (!createSymlink(t, root, alias, 'dir')) return;
526
+ const npm = join(root, 'fake-npm.cjs');
527
+ writeFileSync(npm, `const { mkdirSync, writeFileSync, realpathSync } = require('node:fs');
528
+ const { join } = require('node:path');
529
+ ${packageFiles.toString()}
530
+ const prefix = process.argv[process.argv.indexOf('--prefix') + 1];
531
+ if (prefix !== realpathSync(prefix) || process.cwd() !== prefix) process.exit(91);
532
+ packageFiles(prefix, 'test-tool', '1.0.0');
533
+ writeFileSync(join(prefix, 'canonical-proof.json'), JSON.stringify({prefix,cwd:process.cwd()}));
534
+ `);
535
+ const manager = new ToolManager({ root: alias, catalog: { 'test-tool': config }, npm: () => npm, fetchRelease: async () => release('1.0.0') });
536
+ const installed = await manager.install('test-tool');
537
+ const proof = JSON.parse(readFileSync(join(installed.prefix, 'canonical-proof.json'), 'utf8'));
538
+ assert.equal(proof.cwd, proof.prefix); assert.ok(proof.prefix.startsWith(realpathSync(root)));
539
+ assert.equal(manager.check('test-tool').version, '1.0.0');
540
+ });
541
+
522
542
  test('setup padrao prepara todas as ferramentas compativeis e confirma cada instalacao', async t => {
523
543
  const unsupported = { ...tool('unsupported-tool'), platforms: [process.platform === 'darwin' ? 'linux-x64' : 'darwin-arm64'] };
524
544
  const { root, manager, state, local } = fixture(t, { 'test-tool': tool(), 'unsupported-tool': unsupported });
@@ -599,6 +619,62 @@ test('executables valida plataforma e caminhos relativos sem autorizar comandos
599
619
  }
600
620
  });
601
621
 
622
+ test('executaveis opcionais validam pacotes e caminhos sem permitir escapes', () => {
623
+ const target = platformKey(); const base = tool();
624
+ const good = { [target]: [{ package: '@owner/cli-host', file: 'bin/probe' }] };
625
+ assert.deepEqual(validateToolConfig('test-tool', { ...base, optionalExecutables: good }).optionalExecutables, good);
626
+ for (const item of [
627
+ { package: '../outside', file: 'bin/probe' }, { package: '@owner/cli-host', file: '../outside' },
628
+ { package: '@owner/cli-host', file: '/absolute' }, { package: '@owner/cli-host', file: 'C:\\outside' },
629
+ { package: '@owner/cli-host', file: 'bin/probe', extra: true }
630
+ ]) assert.throws(() => validateToolConfig('test-tool', { ...base, optionalExecutables: { [target]: [item] } }), /opcional|relativo/);
631
+ assert.throws(() => validateToolConfig('test-tool', { ...base, optionalExecutables: { [target]: [good[target][0], good[target][0]] } }), /relativo/);
632
+ });
633
+
634
+ test('atualizacao aceita layout opcional e preserva versao anterior se dependencia falhar', async t => {
635
+ const targets = process.platform === 'win32' ? [platformKey()] : [platformKey(), 'win32-x64'];
636
+ for (const target of targets) await t.test(target, async subtest => {
637
+ const name = '@owner/cli-host'; const isWindowsTarget = target.startsWith('win32-');
638
+ const config = { ...tool(), platforms: [target], ...(!isWindowsTarget ? { executables: { [target]: ['bin/legacy'] } } : {}), optionalExecutables: { [target]: [{ package: name, file: 'bin/probe' }] } };
639
+ const [platform, arch] = target.split('-');
640
+ const { manager, state, local, root } = fixture(subtest, { 'test-tool': config }, { platform, arch });
641
+ let scenario = 'legacy'; const outside = join(root, 'outside-native'); const probe = join(root, 'probe-executado'); writeFileSync(outside, 'untouched'); chmodSync(outside, 0o644);
642
+ const officialTarball = version => `https://registry.npmjs.org/${name}/-/cli-host-${version}.tgz`;
643
+ manager.fetchOptional = async (pkgName, version) => ({ name: pkgName, version, dist: { tarball: officialTarball(version), integrity: 'sha512-fixture' } });
644
+ manager.installPackage = (prefix, pkgName, version) => {
645
+ packageFiles(prefix, pkgName, version);
646
+ const base = join(prefix, 'node_modules', pkgName); mkdirSync(join(base, 'bin'));
647
+ if (scenario === 'legacy') { writeFileSync(join(base, 'bin/legacy'), 'legacy'); return; }
648
+ const manifestFile = join(base, 'package.json'); const manifest = JSON.parse(readFileSync(manifestFile));
649
+ manifest.optionalDependencies = { [name]: version }; writeFileSync(manifestFile, JSON.stringify(manifest));
650
+ writeFileSync(join(base, 'cli.cjs'), `require('node:fs').writeFileSync(${JSON.stringify(probe)}, 'executado'); console.log(${JSON.stringify(version)});`);
651
+ if (scenario === 'missing') return;
652
+ const optional = join(prefix, 'node_modules', name);
653
+ if (scenario === 'package-link') { mkdirSync(dirname(optional), { recursive: true }); symlinkSync(root, optional, 'dir'); return; }
654
+ mkdirSync(join(optional, 'bin'), { recursive: true });
655
+ writeFileSync(join(optional, 'package.json'), JSON.stringify({ name, version: scenario === 'version' ? '9.9.9' : version }));
656
+ const lockFile = join(prefix, 'package-lock.json'); const lock = JSON.parse(readFileSync(lockFile));
657
+ lock.packages[`node_modules/${name}`] = { version, integrity: scenario === 'integrity' ? 'sha512-other-registry' : 'sha512-fixture', resolved: scenario === 'registry' ? `https://example.invalid/${name}/-/cli-host-${version}.tgz` : officialTarball(version) };
658
+ writeFileSync(lockFile, JSON.stringify(lock));
659
+ if (scenario === 'escape') symlinkSync(outside, join(optional, 'bin/probe'));
660
+ else { writeFileSync(join(optional, 'bin/probe'), 'probe'); chmodSync(join(optional, 'bin/probe'), 0o644); }
661
+ if (scenario === 'legacy-escape') symlinkSync(outside, join(base, 'bin/legacy'));
662
+ };
663
+ await manager.install('test-tool'); const before = readFileSync(join(local, 'CURRENT.json'), 'utf8'); state.version = '2.0.0';
664
+ for (scenario of ['missing', 'version', 'integrity', 'registry', ...(process.platform === 'win32' ? [] : ['escape', 'package-link', ...(!isWindowsTarget ? ['legacy-escape'] : [])])]) {
665
+ await assert.rejects(manager.update('test-tool'), /ausente|corresponde|fora do pacote|nao pode ser um link/);
666
+ assert.equal(readFileSync(join(local, 'CURRENT.json'), 'utf8'), before);
667
+ assert.equal(manager.check('test-tool').version, '1.0.0');
668
+ assert.equal(existsSync(probe), false);
669
+ assert.ok(readdirSync(local).every(entry => !entry.startsWith('.stage-')));
670
+ }
671
+ if (process.platform !== 'win32') assert.equal(statSync(outside).mode & 0o777, 0o644);
672
+ scenario = 'optional'; const updated = await manager.update('test-tool');
673
+ assert.equal(updated.version, '2.0.0'); assert.equal(manager.check('test-tool').version, '2.0.0');
674
+ if (process.platform !== 'win32') assert.equal(statSync(join(updated.prefix, 'node_modules', name, 'bin/probe')).mode & 0o777, 0o755);
675
+ });
676
+ });
677
+
602
678
  test('executables prepara somente binarios da plataforma no staging antes de executar probe real', { skip: process.platform === 'win32' }, async t => {
603
679
  const other = process.platform === 'darwin' ? 'linux-x64' : 'darwin-arm64';
604
680
  const config = { ...tool(), platforms: [platformKey(), other], executables: { [platformKey()]: ['native/probe with space'], [other]: ['native/unused'] } };
@@ -38,16 +38,14 @@ Após a instalação, depende somente de Node e do hub local, sem o pacote npm.
38
38
  Ele respeita os clientes escolhidos, sem recriar os desmarcados. Para trocar
39
39
  a seleção, use `agentdevkit agents <lista>`; não edite o manifesto gerado.
40
40
 
41
- Os derivados ficam locais: catálogos, manifesto, estado e apontamentos são
42
- excluídos pelo Git. O bloco em `.git/info/exclude` preserva regras próprias;
43
- sem Git, informa que a proteção fica pendente até `git init` e sincronização.
44
- Somente arquivos integralmente gerados são excluídos; pastas de clientes podem
45
- conter arquivos humanos versionados. Conteúdo próprio não é desindexado pela
46
- 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.
47
46
  Entradas novas usam os templates; hashes em `state/derived.json` permitem
48
47
  atualizar arquivos gerados e blocos separadamente, preservando alterações humanas.
49
- `--check` também acusa derivados ainda rastreados; não altera o índice.
48
+ `--check` somente verifica; não altera o índice.
50
49
  Apontamentos obsoletos podem ser removidos quando a propriedade é comprovada;
51
50
  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.
51
+ devem permanecer.
@@ -42,7 +42,7 @@ test('sem Git preserva ignore e informa pendência; após git init protege outpu
42
42
  git(root, ['add', '--all']);
43
43
  const tracked = git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
44
44
  for (const path of outputs) assert.equal(tracked.includes(path), false, path);
45
- for (const path of ['.github/workflows/ci.yml', '.github/ISSUE_TEMPLATE/bug.yml', '.vscode/settings.json', '.github/skills/human/SKILL.md']) assert.ok(tracked.includes(path), path);
45
+ for (const path of ['.github/workflows/ci.yml', '.github/ISSUE_TEMPLATE/bug.yml', '.vscode/settings.json', '.github/skills/human/SKILL.md']) assert.equal(tracked.includes(path), false, path);
46
46
  });
47
47
 
48
48
  test('prefixos e projetos distintos preservam blocos locais independentes', t => {
@@ -58,13 +58,49 @@ test('prefixos e projetos distintos preservam blocos locais independentes', t =>
58
58
  assert.match(content, /first\\ space/); assert.match(content, /second/);
59
59
  });
60
60
 
61
- test('check identifica negação humana e outputs rastreados sem alterar o índice', t => {
61
+ test('projeto aninhado sob ancestral ignorado falha antes de alterar índices ou excludes', t => {
62
+ const root = fixture(t); git(root, ['init', '-q']);
63
+ put(root, '.gitignore', '/.github/\n');
64
+ const child = join(root, '.github/project');
65
+ put(child, '.gitignore', '!/.claude/\n');
66
+ put(child, '.claude/custom.md', 'instruções próprias\n');
67
+ const exclude = readFileSync(join(root, '.git/info/exclude'));
68
+ const index = git(root, ['ls-files', '--stage', '-z']);
69
+ const plan = planLocalIgnore(child, { outputs: ['.claude/custom.md'] });
70
+ assert.ok(checkLocalIgnore(plan).missing.some(value => value.includes('Ancestral Git ignorado: .github/')));
71
+ assert.throws(() => applyLocalIgnore(plan), /Ancestral Git ignorado/);
72
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), exclude);
73
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
74
+ assert.equal(readFileSync(join(child, '.claude/custom.md'), 'utf8'), 'instruções próprias\n');
75
+ put(root, '.gitignore', '/.github/\n!/.github/\n');
76
+ const opened = planLocalIgnore(child, { outputs: ['.claude/custom.md'] });
77
+ applyLocalIgnore(opened);
78
+ assert.equal(checkLocalIgnore(planLocalIgnore(child, { outputs: ['.claude/custom.md'] })).ok, true);
79
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.github/project/.claude/custom.md']).status, 1);
80
+ });
81
+
82
+ test('opt-in aninhado respeita também excludesFile configurado pelo usuário', t => {
83
+ const root = fixture(t); git(root, ['init', '-q']);
84
+ put(root, 'global-ignore', '/.private/\n');
85
+ git(root, ['config', '--local', 'core.excludesFile', join(root, 'global-ignore')]);
86
+ const child = join(root, '.private/project');
87
+ put(child, '.gitignore', '!/.claude/\n');
88
+ put(child, '.claude/custom.md', 'instruções próprias\n');
89
+ const before = readFileSync(join(root, '.git/info/exclude'));
90
+ const plan = planLocalIgnore(child);
91
+ assert.ok(checkLocalIgnore(plan).missing.some(value => value.includes('Ancestral Git ignorado: .private/')));
92
+ assert.throws(() => applyLocalIgnore(plan), /Ancestral Git ignorado/);
93
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), before);
94
+ assert.equal(readFileSync(join(child, '.claude/custom.md'), 'utf8'), 'instruções próprias\n');
95
+ });
96
+
97
+ test('check identifica negação humana sem alterar nem diagnosticar conteúdo rastreado legítimo', t => {
62
98
  const root = fixture(t), outputs = ['generated.md']; git(root, ['init', '-q']);
63
99
  put(root, 'generated.md', 'gerado'); git(root, ['add', 'generated.md']);
64
100
  applyLocalIgnore(planLocalIgnore(root, { outputs }));
65
101
  put(root, '.gitignore', '!/generated.md\n');
66
102
  const result = checkLocalIgnore(planLocalIgnore(root, { outputs }));
67
- assert.equal(result.ok, false); assert.deepEqual(result.missing, outputs); assert.deepEqual(result.trackedOutputs, outputs);
103
+ assert.equal(result.ok, false); assert.deepEqual(result.missing, outputs); assert.deepEqual(result.trackedOutputs, []);
68
104
  assert.equal(git(root, ['ls-files', 'generated.md']).trim(), 'generated.md');
69
105
  });
70
106
 
@@ -89,7 +125,7 @@ test('exceções de templates preservam o ignore raiz e protegem dados privados'
89
125
  assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.CACHE/private.json']).includes('.cursor/.CACHE/private.json'));
90
126
  assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.cache/private.json']).includes('.cursor/.cache/private.json'));
91
127
  const visible = spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.cursor/human.md']);
92
- assert.equal(visible.status, 1);
128
+ assert.equal(visible.status, 0);
93
129
  applyLocalIgnore(planLocalIgnore(root));
94
130
  assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), updated);
95
131
  put(root, '.gitignore', updated + '# edição humana posterior\n');
@@ -134,12 +170,12 @@ test('negação humana posterior é informada sem reescrever a raiz', t => {
134
170
  const root = fixture(t); git(root, ['init', '-q']);
135
171
  put(root, '.gitignore', '# humano\n'); applyLocalIgnore(planLocalIgnore(root));
136
172
  const path = join(root, '.gitignore');
137
- writeFileSync(path, readFileSync(path, 'utf8') + '!/.cursor/.env.production\n');
173
+ writeFileSync(path, readFileSync(path, 'utf8') + '!/.cursor/\n!/.cursor/.env.production\n');
138
174
  put(root, '.cursor/.env.production', 'privado');
139
175
  const plan = planLocalIgnore(root);
140
176
  assert.ok(checkLocalIgnore(plan).missing.includes('.cursor/.env.production'));
141
177
  assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
142
- assert.equal(readFileSync(path, 'utf8'), '# humano\n!/.cursor/.env.production\n');
178
+ assert.equal(readFileSync(path, 'utf8'), '# humano\n!/.cursor/\n!/.cursor/.env.production\n');
143
179
  });
144
180
 
145
181
  test('planejamento sem Git não modifica ignore nem edição humana posterior', t => {
@@ -193,11 +229,13 @@ test('desindexação não descarta edição humana staged com workingtree restau
193
229
  assert.equal(readFileSync(join(root, 'generated.md'), 'utf8'), 'gerado\n');
194
230
  });
195
231
 
196
- test('configurações humanas dos clientes continuam versionáveis', t => {
232
+ test('configurações humanas podem ser versionadas após exceção explícita', t => {
197
233
  const root = fixture(t); git(root, ['init', '-q']);
198
234
  applyLocalIgnore(planLocalIgnore(root));
199
235
  put(root, '.codex/config.toml', 'local'); put(root, '.claude/settings.json', 'local');
200
236
  put(root, '.github/settings.json', 'fonte'); put(root, '.vscode/settings.json', 'fonte');
237
+ put(root, '.gitignore', '!/.codex/\n!/.claude/\n!/.github/\n!/.vscode/\n');
238
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
201
239
  git(root, ['add', '--all']);
202
240
  const tracked = git(root, ['ls-files', '-z']).split('\0');
203
241
  assert.equal(tracked.includes('.codex/config.toml'), true);
@@ -205,6 +243,53 @@ test('configurações humanas dos clientes continuam versionáveis', t => {
205
243
  assert.ok(tracked.includes('.github/settings.json')); assert.ok(tracked.includes('.vscode/settings.json'));
206
244
  });
207
245
 
246
+ test('exceções de raiz usam precedência real e mantêm credenciais ignoradas', t => {
247
+ const root = fixture(t); git(root, ['init', '-q']);
248
+ put(root, '.gitignore', '!/.claude/\n!/.github/\n');
249
+ put(root, '.claude/skills/team/SKILL.md', 'skill humana\n');
250
+ put(root, '.claude/auth.json', 'segredo');
251
+ put(root, '.github/workflows/ci.yml', 'workflow humano\n');
252
+ put(root, '.vscode/settings.json', 'configuração humana\n');
253
+ const original = readFileSync(join(root, '.gitignore'));
254
+ const plan = planLocalIgnore(root, { outputs: ['.claude/skills/team/SKILL.md', '.github/workflows/ci.yml'] });
255
+ assert.ok(plan.optedRoots.includes('.claude'));
256
+ assert.ok(plan.optedRoots.includes('.github'));
257
+ applyLocalIgnore(plan);
258
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
259
+ for (const path of ['.claude/skills/team/SKILL.md', '.github/workflows/ci.yml']) {
260
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', path]).status, 1, path);
261
+ }
262
+ for (const path of ['.claude/auth.json', '.vscode/settings.json']) {
263
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', path]).status, 0, path);
264
+ }
265
+ assert.deepEqual(readFileSync(join(root, '.gitignore')), original);
266
+ put(root, '.gitignore', '!/.claude/\n/.claude/\n');
267
+ const overridden = planLocalIgnore(root);
268
+ assert.equal(overridden.optedRoots.includes('.claude'), false);
269
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.claude/skills/team/SKILL.md']).status, 0);
270
+ put(root, '.gitignore', '!/.claude/\n!/.claude/**\n');
271
+ assert.throws(() => applyLocalIgnore(planLocalIgnore(root)), /Regras humanas/);
272
+ });
273
+
274
+ test('derivados e entradas legítimas já rastreados permanecem no índice com ou sem opt-in', t => {
275
+ const root = fixture(t); git(root, ['init', '-q']);
276
+ put(root, '.claude/skills/team/SKILL.md', 'skill rastreada\n');
277
+ put(root, '.github/workflows/ci.yml', 'workflow rastreado\n');
278
+ put(root, 'AGENTS.md', 'instruções rastreadas\n');
279
+ git(root, ['add', '--all']);
280
+ const before = git(root, ['ls-files', '--stage', '-z']);
281
+ const outputs = ['.claude/skills/team/SKILL.md', '.github/workflows/ci.yml', 'AGENTS.md'];
282
+ applyLocalIgnore(planLocalIgnore(root, { outputs }));
283
+ assert.equal(checkLocalIgnore(planLocalIgnore(root, { outputs })).ok, true);
284
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), before);
285
+ put(root, '.gitignore', '!/.claude/\n');
286
+ const opted = planLocalIgnore(root, { outputs });
287
+ assert.ok(opted.optedRoots.includes('.claude'));
288
+ applyLocalIgnore(opted);
289
+ assert.equal(checkLocalIgnore(planLocalIgnore(root, { outputs })).ok, true);
290
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), before);
291
+ });
292
+
208
293
  test('artefatos vêm da política compartilhada e rastreados são informados', t => {
209
294
  const root = fixture(t); git(root, ['init', '-q']);
210
295
  put(root, '.agent-tests/human.txt', 'evidência humana'); git(root, ['add', '-f', '.agent-tests/human.txt']);
@@ -271,12 +356,13 @@ test('probes reconhecem exceções humanas para nomes reais de Playwright', asyn
271
356
  });
272
357
  });
273
358
 
274
- test('milhares de caminhos usam stdin e não ampliam metadata de área já ignorada', t => {
359
+ test('milhares de caminhos usam stdin sem ampliar metadata de área já ignorada', t => {
275
360
  const root = fixture(t); git(root, ['init', '-q']); put(root, '.gitignore', '/ignored/\n');
276
361
  const before = readFileSync(join(root, '.git/info/exclude'), 'utf8');
277
362
  const target = join(root, 'ignored'); mkdirSync(target);
278
- const outputs = Array.from({ length: 1500 }, (_, index) => `.github/skills/generated-${index}-${'x'.repeat(45)}/SKILL.md`);
363
+ const outputs = Array.from({ length: 1500 }, (_, index) => `generated-${index}-${'x'.repeat(45)}.md`);
279
364
  const plan = planLocalIgnore(target, { outputs });
365
+ assert.ok(plan.paths.length >= 1500);
280
366
  assert.deepEqual(plan.edits, []); assert.equal(checkLocalIgnore(plan).ok, true);
281
367
  applyLocalIgnore(plan); assert.equal(readFileSync(join(root, '.git/info/exclude'), 'utf8'), before);
282
368
  assert.deepEqual(planUntrack(target, Object.fromEntries(outputs.map(path => [path, 'a'.repeat(64)]))).paths, []);
@@ -341,16 +427,17 @@ test('cliente não selecionado com symlink próprio não é atravessado pelo pro
341
427
  assert.deepEqual(readdirSync(outside), before);
342
428
  });
343
429
 
344
- test('preflight dispensa somente regras de bloco substituído com hash válido', t => {
430
+ test('preflight valida hash do bloco substituído sem impor exclusão sobre cliente optado', t => {
345
431
  const root = fixture(t); git(root, ['init', '-q']);
346
432
  const block = '# BEGIN AGENT IGNORE\r\n!/generated.md\r\n# END AGENT IGNORE';
433
+ put(root, '.gitignore', '!/.github/\n');
347
434
  put(root, '.github/.gitignore', `# Humano\r\n${block}\r\n!/human.md\r\n`);
348
435
  const plan = planLocalIgnore(root, { outputs: ['.github/generated.md', '.github/human.md'] });
349
436
  const replacement = { path: join(root, '.github/.gitignore'), firstLine: 2, lastLine: 4, sha256: createHash('sha256').update(block).digest('hex') };
350
- assert.deepEqual(checkLocalIgnore(plan).missing, ['.github/generated.md', '.github/human.md']);
351
- assert.deepEqual(checkLocalIgnore(plan, { replacedBlocks: [replacement] }).missing, ['.github/human.md']);
437
+ assert.deepEqual(checkLocalIgnore(plan).missing, []);
438
+ assert.deepEqual(checkLocalIgnore(plan, { replacedBlocks: [replacement] }).missing, []);
352
439
  assert.throws(() => checkLocalIgnore(plan, { replacedBlocks: [{ ...replacement, sha256: '0'.repeat(64) }] }), /Bloco de ignore mudou/);
353
- assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
440
+ assert.doesNotThrow(() => applyLocalIgnore(plan));
354
441
  assert.equal(readFileSync(join(root, '.github/.gitignore'), 'utf8'), `# Humano\r\n${block}\r\n!/human.md\r\n`);
355
442
  });
356
443
 
@@ -490,14 +577,14 @@ test('dependências já ignoradas não causam conflito, mas ignores visíveis s
490
577
  assert.ok(checkLocalIgnore(planLocalIgnore(root)).missing.some(value => value.includes('!.playwright-*/')));
491
578
  });
492
579
 
493
- test('bloco antigo de outro caminho não oculta arquivos humanos silenciosamente', t => {
580
+ test('bloco de outra worktree não bloqueia a política local de clientes', t => {
494
581
  const root = fixture(t); git(root, ['init', '-q']);
495
582
  const old = '# BEGIN AGENTDEVKIT LOCAL 0123456789abcdef\n/.claude/\n# END AGENTDEVKIT LOCAL 0123456789abcdef\n';
496
583
  put(root, '.git/info/exclude', old);
497
584
  const plan = planLocalIgnore(root);
498
- assert.ok(checkLocalIgnore(plan).missing.includes('.claude/.agentdevkit-human-probe.md'));
499
- assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
500
- assert.equal(readFileSync(join(root, '.git/info/exclude'), 'utf8'), old);
585
+ assert.equal(checkLocalIgnore(plan).missing.length, 0);
586
+ assert.doesNotThrow(() => applyLocalIgnore(plan));
587
+ assert.ok(readFileSync(join(root, '.git/info/exclude'), 'utf8').startsWith(old));
501
588
  });
502
589
 
503
590
  test('artefatos já versionados são informados sem alterar o índice', t => {
@@ -555,6 +642,57 @@ test('escrita de exclusões preserva permissões e detecta alteração concorren
555
642
  assert.equal(statSync(exclude).mode & 0o777, 0o640);
556
643
  });
557
644
 
645
+ test('alteração no exclude durante escrita temporária é preservada antes da promoção', t => {
646
+ const root = fixture(t); git(root, ['init', '-q']);
647
+ const file = join(root, '.git/info/exclude'), before = readFileSync(file, 'utf8');
648
+ const plan = planLocalIgnore(root), index = git(root, ['ls-files', '--stage', '-z']);
649
+ const write = fs.writeFileSync; let injected = false;
650
+ try {
651
+ fs.writeFileSync = (path, content, options) => {
652
+ const value = write(path, content, options);
653
+ if (!injected && String(path).startsWith(`${file}.agentdevkit-`)) {
654
+ injected = true;
655
+ write(file, `${before}\n# regra humana concorrente\n`);
656
+ }
657
+ return value;
658
+ };
659
+ syncBuiltinESMExports();
660
+ assert.throws(() => applyLocalIgnore(plan), /Exclusao alterada antes de publicar/);
661
+ } finally { fs.writeFileSync = write; syncBuiltinESMExports(); }
662
+ assert.equal(injected, true);
663
+ assert.equal(readFileSync(file, 'utf8'), `${before}\n# regra humana concorrente\n`);
664
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
665
+ });
666
+
667
+ test('alteração no exclude durante rollback não é substituída', t => {
668
+ const root = fixture(t); git(root, ['init', '-q']);
669
+ const file = join(root, '.git/info/exclude'), plan = planLocalIgnore(root);
670
+ const index = git(root, ['ls-files', '--stage', '-z']);
671
+ const write = fs.writeFileSync, remove = fs.rmSync;
672
+ let failed = false, injected = false;
673
+ try {
674
+ fs.rmSync = (path, options) => {
675
+ if (!failed && String(path).startsWith(`${file}.agentdevkit-`) && !existsSync(path)) {
676
+ failed = true; throw new Error('falha após promoção');
677
+ }
678
+ return remove(path, options);
679
+ };
680
+ fs.writeFileSync = (path, content, options) => {
681
+ const value = write(path, content, options);
682
+ if (failed && !injected && String(path).startsWith(`${file}.agentdevkit-`)) {
683
+ injected = true;
684
+ write(file, `${readFileSync(file, 'utf8')}\n# regra humana no rollback\n`);
685
+ }
686
+ return value;
687
+ };
688
+ syncBuiltinESMExports();
689
+ assert.throws(() => applyLocalIgnore(plan), /rollback incompleto.*conteudo preservado/s);
690
+ } finally { fs.rmSync = remove; fs.writeFileSync = write; syncBuiltinESMExports(); }
691
+ assert.equal(injected, true);
692
+ assert.match(readFileSync(file, 'utf8'), /# regra humana no rollback/);
693
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
694
+ });
695
+
558
696
  test('ignore raiz mantém modo e bytes antes e depois de git init', { skip: process.platform === 'win32' }, t => {
559
697
  const root = fixture(t), ignore = join(root, '.gitignore');
560
698
  put(root, '.gitignore', '# humano\n'); chmodSync(ignore, 0o600);
@@ -375,19 +375,36 @@ function ignoreFixture(t) {
375
375
  return context;
376
376
  }
377
377
 
378
- test('fontes novas permanecem elegíveis e derivados locais ficam fora do índice', t => {
378
+ test('fontes permanecem elegíveis e pastas de clientes aceitam opt-in explícito', t => {
379
379
  const { root, file } = ignoreFixture(t);
380
380
  const outputs = ['.github/skills/generated/SKILL.md', '.vscode/.gitignore', '.github/.gitignore'];
381
- applyLocalIgnore(planLocalIgnore(root, { outputs }));
382
- const sources = ['src/new.mjs', 'bin/new.mjs', 'tests/new.test.mjs', 'scripts/release/new.mjs', '.github/workflows/own.yml', '.github/ISSUE_TEMPLATE/bug.yml', '.github/skills/human/SKILL.md', '.vscode/settings.json'];
381
+ const sources = ['src/new.mjs', 'bin/new.mjs', 'tests/new.test.mjs', 'scripts/release/new.mjs'];
382
+ const clients = ['.github/workflows/own.yml', '.github/ISSUE_TEMPLATE/bug.yml', '.github/skills/human/SKILL.md', '.vscode/settings.json'];
383
383
  const generated = [...outputs, '.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/espelhos.conf', '.pbai4devs/state/agentdevkit.json'];
384
- for (const path of [...sources, ...generated]) file(path, 'fixture\n');
384
+ for (const path of [...sources, ...clients, ...generated.filter(path => !path.endsWith('/.gitignore'))]) file(path, 'fixture\n');
385
+ file('.github/auth.json', 'credencial local\n');
386
+ file('.vscode/logs/session.log', 'artefato local\n');
387
+ assert.equal(git(root, ['add', '--', clients[0], sources[0]]).status, 0);
388
+ const index = git(root, ['ls-files', '--stage', '-z']).stdout;
389
+ applyLocalIgnore(planLocalIgnore(root, { outputs }));
385
390
  assert.deepEqual(ignoredPaths(root, sources), []);
391
+ assert.deepEqual(new Set(ignoredPaths(root, clients)), new Set(clients));
386
392
  assert.deepEqual(new Set(ignoredPaths(root, generated)), new Set(generated));
393
+ assert.equal(git(root, ['ls-files', '--stage', '-z']).stdout, index);
394
+ for (const path of clients) assert.equal(readFileSync(join(root, path), 'utf8'), 'fixture\n', path);
395
+
396
+ const exceptions = '!/.github/\n!/.vscode/\n';
397
+ file('.gitignore', exceptions);
398
+ applyLocalIgnore(planLocalIgnore(root, { outputs }));
399
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), exceptions);
400
+ assert.equal(git(root, ['ls-files', '--stage', '-z']).stdout, index);
401
+ assert.deepEqual(ignoredPaths(root, clients), []);
402
+ const privateFiles = ['.github/auth.json', '.vscode/logs/session.log'];
403
+ assert.deepEqual(new Set(ignoredPaths(root, privateFiles)), new Set(privateFiles));
387
404
  assert.equal(git(root, ['add', '--all']).status, 0);
388
405
  const tracked = git(root, ['ls-files', '-z']).stdout.split('\0');
389
- for (const path of sources) assert.ok(tracked.includes(path), path);
390
- for (const path of generated) assert.equal(tracked.includes(path), false, path);
406
+ for (const path of [...sources, ...clients, ...outputs]) assert.ok(tracked.includes(path), path);
407
+ for (const path of [...generated.filter(path => !outputs.includes(path)), ...privateFiles]) assert.equal(tracked.includes(path), false, path);
391
408
  });
392
409
 
393
410
  test('ignores locais cobrem resultados em qualquer caixa sem bloquear fixtures', t => {
@@ -7,14 +7,21 @@ description: Exemplo opcional de regras de ignore para o projeto.
7
7
  O `.gitignore` da raiz pertence ao projeto; o kit não o cria nem o altera.
8
8
  O projeto pode adotar este exemplo e seus próprios padrões se desejar.
9
9
  Regras de artefatos locais vêm de [shared.ignore](shared.ignore) e
10
- [agents.ignore](agents.ignore). As saídas calculadas usam
11
- [local.ignore](local.ignore) em `.git/info/exclude`; sem Git, essa proteção
12
- fica pendente até `git init` e sincronização, antes de adicionar arquivos.
10
+ [agents.ignore](agents.ignore). As pastas dos clientes usam
11
+ [local.ignore](local.ignore) em `.git/info/exclude`; o `.gitignore` deste hub
12
+ não alcança pastas irmãs com `../`. As regras locais não são copiadas pelo Git:
13
+ execute `init` ou `sync` em cada clone com Git inicializado, antes de adicionar
14
+ arquivos. Worktrees do mesmo repositório podem compartilhar as exclusões;
15
+ sem Git, essa proteção fica pendente.
13
16
  O sincronizador mantém o bloco `BEGIN AGENT IGNORE` / `END AGENT IGNORE`;
14
- regras próprias ficam fora dele, com exceções depois do bloco. Para permitir
15
- novos arquivos compartilhados, ajuste as fontes e sincronize. Raízes dedicadas
16
- dos clientes são locais. Em `.github` e `.vscode`, arquivos próprios continuam
17
- elegíveis; somente os derivados são excluídos.
17
+ regras próprias ficam fora dele, com exceções depois do bloco. Arquivos já
18
+ rastreados permanecem no índice; o kit não os desindexa automaticamente.
19
+ Pastas dos clientes também podem conter arquivos humanos ainda não rastreados.
20
+ Em especial, `.github` pode conter workflows e formulários do projeto.
21
+ Para permitir o versionamento, acrescente uma exceção literal ao `.gitignore`
22
+ da raiz, como `!/.claude/` ou `!/.github/`. Regras mais profundas continuam
23
+ válidas. Evite exceções amplas como `!/.claude/**`, que podem expor arquivos
24
+ privados e artefatos; `doctor` informa regras conflitantes.
18
25
  Ignore binários baixados por local, não por extensão: `.bat`, `.exe` e outros
19
26
  arquivos podem ser scripts ou fixtures necessários. Não use regras genéricas
20
27
  como `Release/`, `Scripts/` ou `Lib/` que escondam fontes e modelos.
@@ -1,3 +1,4 @@
1
+ # Pastas irmas: templates/gitignore/local.ignore e .git/info/exclude.
1
2
  # Derivados locais
2
3
  /catalog.json
3
4
  /skills.json
@@ -24,6 +24,14 @@
24
24
  "linux-arm64": ["bin/agent-browser-linux-arm64", "bin/agent-browser-linux-musl-arm64"],
25
25
  "linux-x64": ["bin/agent-browser-linux-x64", "bin/agent-browser-linux-musl-x64"]
26
26
  },
27
+ "optionalExecutables": {
28
+ "darwin-arm64": [{"package": "@agent-browser/cli-darwin-arm64", "file": "bin/agent-browser"}],
29
+ "darwin-x64": [{"package": "@agent-browser/cli-darwin-x64", "file": "bin/agent-browser"}],
30
+ "linux-arm64": [{"package": "@agent-browser/cli-linux-arm64-gnu", "file": "bin/agent-browser"}, {"package": "@agent-browser/cli-linux-arm64-musl", "file": "bin/agent-browser"}],
31
+ "linux-x64": [{"package": "@agent-browser/cli-linux-x64-gnu", "file": "bin/agent-browser"}, {"package": "@agent-browser/cli-linux-x64-musl", "file": "bin/agent-browser"}],
32
+ "win32-arm64": [{"package": "@agent-browser/cli-win32-x64-msvc", "file": "bin/agent-browser.exe"}],
33
+ "win32-x64": [{"package": "@agent-browser/cli-win32-x64-msvc", "file": "bin/agent-browser.exe"}]
34
+ },
27
35
  "scripts": {
28
36
  "entry": "SCRIPT.mjs"
29
37
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.20 — 2026-11-13
8
+
9
+ - Atualização da ferramenta agent-browser compatível com sua distribuição atual.
10
+ - Orientações para consultar e recuperar versões pelas tags publicadas.
11
+
12
+ ## 0.0.19 — 2026-11-13
13
+
14
+ - Pastas dos clientes são ignoradas localmente por padrão, com exceções do projeto.
15
+ - O índice Git e os arquivos já rastreados permanecem intactos.
16
+
7
17
  ## 0.0.18 — 2026-11-13
8
18
 
9
19
  - Sincronização preserva recursos próprios, colisões e alterações humanas.
package/README.en.md CHANGED
@@ -13,7 +13,7 @@ Organize work with coding agents, from planning to verification. Keep skills,
13
13
  rules, commands and agent profiles in `.pbai4devs`; the kit generates the
14
14
  references each client can discover. Code lives in `src/`, product tests in
15
15
  `tests/`, and shared agent resources in `.pbai4devs/`. Generated client
16
- folders, entrypoints and preferences stay local and untracked.
16
+ folders are ignored locally by default; projects can opt in to tracking them.
17
17
  See the [supported clients][clients].
18
18
 
19
19
  ## Start in your project
package/README.es.md CHANGED
@@ -13,8 +13,8 @@ Organiza el trabajo con agentes de código, desde la planificación hasta la
13
13
  verificación. Mantén skills, reglas, comandos y perfiles en `.pbai4devs`;
14
14
  el kit genera las referencias de cada [cliente compatible][clients].
15
15
  El código vive en `src/`, las pruebas del producto en `tests/` y los recursos
16
- compartidos en `.pbai4devs/`. Las carpetas, entradas y preferencias generadas
17
- son locales y no se versionan.
16
+ compartidos en `.pbai4devs/`. Las carpetas de clientes se ignoran localmente
17
+ por defecto; cada proyecto puede elegir versionarlas.
18
18
 
19
19
  ## Empezar en tu proyecto
20
20
 
@@ -85,10 +85,12 @@ comando agêntico [sincronizar](../../.pbai4devs/commands/sincronizar/COMMAND.md
85
85
  orienta executar o script com `--force`; ele não é o mesmo que `sync` da CLI.
86
86
 
87
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.
88
+ `.git/info/exclude` para ocultar por padrão as pastas dos clientes. O projeto
89
+ pode reabri-las no `.gitignore` da raiz, como em `!/.claude/`; consulte as
90
+ [exceções e seus efeitos](installation.md#git-e-migração). Os comandos não
91
+ retiram arquivos já rastreados do índice, e `untrackedPaths` permanece vazio
92
+ por compatibilidade. Backups locais ficam em `.agent-tests/` e não substituem
93
+ o histórico Git.
92
94
 
93
95
  ## Ferramentas
94
96
 
@@ -87,7 +87,7 @@ O próximo passo é [preparar as ferramentas](prerequisites.md#depois-de-instala
87
87
  | Conteúdo | Tratamento |
88
88
  | --- | --- |
89
89
  | `.pbai4devs` com recursos e templates | Fonte compartilhada versionável. |
90
- | Skills, regras e comandos nos clientes | Apontamentos locais gerados. |
90
+ | Recursos nos clientes | Apontamentos locais; pastas ignoradas por padrão. |
91
91
  | Entradas da raiz criadas pelo kit | Arquivos locais gerados. |
92
92
  | Índices, manifesto e seleção pessoal | Derivados locais ignorados. |
93
93
  | Código, documentos e configurações humanas | Preservados. |
@@ -102,12 +102,31 @@ identifica a licença dos recursos do AgentDevKit, sem redefinir a do projeto.
102
102
 
103
103
  ## Git e migração
104
104
 
105
- Um `.gitignore` dentro de `.pbai4devs` só controla essa árvore. Para os
106
- apontamentos externos, o kit gera exclusões locais do Git, sem ignorar
107
- workflows, formulários de issues ou configurações humanas em `.github`.
108
- O kit não cria nem altera o `.gitignore` da raiz. Sem Git, a exclusão local
109
- fica pendente; execute `git init` e `npx @pathbit/agentdevkit sync` antes de
110
- usar `git add`. No GitHub Packages, use o prefixo de registry da instalação.
105
+ Um `.gitignore` dentro de `.pbai4devs` só controla essa árvore: `../` não
106
+ alcança pastas irmãs. O kit gera regras em `.git/info/exclude` para ignorar
107
+ por padrão as pastas dos clientes, sem alterar o `.gitignore` da raiz.
108
+ Essas regras são locais, não acompanham o clone; execute `init` ou `sync`
109
+ em cada clone com Git inicializado, antes de usar `git add`. Sem Git, a
110
+ exclusão fica pendente até `git init` e sincronização.
111
+ Worktrees do mesmo repositório podem compartilhar esse arquivo de exclusões.
112
+
113
+ Para versionar uma pasta de cliente, o projeto pode acrescentar uma exceção
114
+ literal ao seu próprio `.gitignore` da raiz, por exemplo:
115
+
116
+ ```gitignore
117
+ !/.claude/
118
+ !/.github/
119
+ ```
120
+
121
+ A exceção da raiz tem precedência sobre `.git/info/exclude`; regras de um
122
+ `.gitignore` mais profundo continuam valendo. Não use `!/.claude/**`: um
123
+ curinga amplo pode reabrir credenciais e artefatos que devem ficar locais.
124
+ O diagnóstico informa exceções que exponham esses arquivos.
125
+
126
+ O padrão também oculta arquivos humanos ainda não rastreados nas pastas dos
127
+ clientes. Em especial, `.github` pode conter workflows, formulários de issues
128
+ e outras configurações do projeto; use `!/.github/` se precisar versioná-los.
129
+ O mesmo cuidado vale para `.vscode` e configurações próprias de outros agentes.
111
130
 
112
131
  O bloco local também protege `.agent-tests`, `tmp`, `temp`, `.playwright*`
113
132
  e relatórios de testes, inclusive com letras maiúsculas e em subpastas.
@@ -119,15 +138,10 @@ de existir um arquivo correspondente. Examine a regra indicada no diagnóstico.
119
138
  Nomes próprios de artefatos e arquivos existentes também são conferidos;
120
139
  a validação fica restrita ao projeto e aos ignores de seus ancestrais.
121
140
 
122
- Arquivos já rastreados exigem migração do índice: apenas saídas identificadas
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.
127
- Artefatos já rastreados são informados por `doctor` e `sync --check` para
128
- revisão; não são removidos automaticamente do índice.
129
- Revise o plano e os conflitos em um projeto existente; use `--dry-run` antes
130
- de aplicar. Conteúdo humano não deve ser classificado como saída do gerador.
141
+ Ignorar uma pasta não retira arquivos já rastreados do índice. O kit preserva
142
+ o índice, inclusive alterações preparadas; para mudar o rastreamento, o projeto
143
+ decide a ação Git. `--dry-run` e `sync --check` também não alteram o índice.
144
+ Revise o plano e os conflitos antes de aplicar em um projeto existente.
131
145
 
132
146
  ## Atualizar e escolher agentes
133
147
 
@@ -137,9 +151,9 @@ continua válida; `agents` consulta e `agents codex,claude` altera a seleção.
137
151
  necessárias para outro cliente, como `.agents` para Codex e Antigravity.
138
152
 
139
153
  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.
154
+ Revise arquivos criados, atualizados e removidos, conflitos e `preserved`.
155
+ `sync` usa as fontes já instaladas e não baixa uma nova versão. Consulte a
156
+ [referência da CLI](cli.md) para todos os comandos.
143
157
 
144
158
  Skills e outros recursos próprios podem coexistir com os do kit. Um nome
145
159
  igual não comprova propriedade: conteúdo humano e arquivos adicionais não
@@ -165,8 +179,8 @@ dele: atualizações alteram somente a contribuição do kit. Nomes de entradas
165
179
  com caixa diferente são reaproveitados quando há uma única opção.
166
180
  Comandos Gemini mantêm a referência dentro do valor `prompt` em TOML válido.
167
181
  Configurações que não são instruções não recebem uma mesclagem arbitrária.
168
- Arquivos mistos permanecem sob controle do Git do projeto; somente derivados
169
- integralmente gerados pelo kit recebem exclusão local.
182
+ Arquivos mistos não passam a pertencer ao kit. Sua visibilidade para o Git
183
+ também depende da regra aplicada à pasta do cliente.
170
184
 
171
185
  Links simbólicos preexistentes são preservados sem atravessá-los. O campo
172
186
  `preserved` informa rotas que continuam sob controle do projeto. Um formato
@@ -164,8 +164,9 @@ não devem ser versionados. Verifique com `git status` e `git check-ignore -v
164
164
  retira automaticamente do Git; revise a remoção do índice sem apagar dados.
165
165
 
166
166
  O `.gitignore` do hub não controla pastas irmãs. Confira a proteção local
167
- gerada pelo kit, incluindo `info/exclude` nos repositórios Git. Preserve
168
- configurações humanas em `.github` e `.vscode` ao migrar arquivos antigos.
167
+ gerada pelo kit em `info/exclude`. Para versionar configurações humanas novas
168
+ em `.github` ou `.vscode`, reabra a pasta com `!/.github/` ou `!/.vscode/`
169
+ no `.gitignore` da raiz. Confira as regras com `git check-ignore -v`.
169
170
 
170
171
  Para pedir ajuda, inclua SO/arquitetura, versões de Node/npm/AgentDevKit,
171
172
  comando executado, código de saída e trecho relevante do JSON, sem segredos.
@@ -85,6 +85,28 @@ Também envia o mesmo pacote ao [npm público e GitHub Packages](packages.md),
85
85
  confere a integridade nos dois destinos e testa versão, instalação, diagnóstico
86
86
  e sincronização. O consumo pelo npm é anônimo; pelo GitHub, autenticado.
87
87
 
88
+ A tag é a origem da publicação: `v0.0.19`, a release `v0.0.19` e os pacotes
89
+ `0.0.19` apontam para a mesma entrega. A execução manual também exige a tag;
90
+ não crie outra tag para uma versão nem mova uma tag já publicada.
91
+
92
+ Para consultar o código de uma versão sem trocar os arquivos atuais:
93
+
94
+ ```console
95
+ git fetch --tags
96
+ git worktree add --detach ../AgentDevKit-v0.0.19 v0.0.19
97
+ ```
98
+
99
+ Em um projeto atendido, fixe a versão desejada do pacote e revise o plano antes
100
+ de aplicá-la:
101
+
102
+ ```console
103
+ npx @pathbit/agentdevkit@0.0.19 update --dry-run --json
104
+ ```
105
+
106
+ O comando usa aquela versão do kit; não reverte automaticamente o histórico
107
+ Git nem arquivos próprios do projeto. Confira os conflitos e faça backup antes
108
+ de aplicar uma versão anterior.
109
+
88
110
  Se o artifact expirou ou um job do CI falhou, use **Re-run all jobs** no CI
89
111
  original do push e aguarde todos os jobs. **Re-run failed jobs** pode manter o
90
112
  pacote de uma tentativa anterior, que a release não aceita. Depois reexecute
@@ -60,13 +60,13 @@ bloco exigem revisão e não autorizam sobrescrever o arquivo com `--force`.
60
60
  Arquivos novos e integralmente gerados continuam com propriedade integral.
61
61
  Comandos TOML recebem a referência no valor de `prompt`, preservando as demais
62
62
  chaves. Configurações nativas fora desse escopo permanecem sob controle humano.
63
- Nunca é possível ignorar somente um trecho de arquivo no Git: arquivos mistos
64
- não entram nas exclusões do kit. Veja a [spec de integração][additive].
63
+ Nunca é possível ignorar somente um trecho de arquivo no Git. Veja a
64
+ [spec de integração][additive].
65
65
 
66
- Saídas identificadas são excluídas localmente do versionamento. O Git local
67
- usa `info/exclude`; fora de um repositório, a proteção fica no `.gitignore`
68
- da raiz. A política vem dos templates, não de listas copiadas por cliente.
69
- Os arquivos humanos de `.github` e `.vscode` continuam versionáveis.
66
+ O Git usa `info/exclude` para ignorar localmente pastas de clientes, inclusive
67
+ seus arquivos humanos ainda não rastreados. Exceções literais no `.gitignore`
68
+ da raiz permitem versioná-las. Arquivos já rastreados e o índice permanecem;
69
+ sem Git, a proteção fica pendente. A política vem dos templates.
70
70
 
71
71
  O estado registra versão, escolha e propriedade local. Índices e manifesto
72
72
  podem ser reconstruídos. Downloads, credenciais e relatórios ficam fora do
@@ -25,6 +25,8 @@ description: Incrementos verificáveis do AgentDevKit.
25
25
  - [Integração sem substituição][additive]: acréscimos em arquivos existentes.
26
26
  - [Compatibilidade com projetos reais][oss]: projetos públicos e exemplos reproduzíveis.
27
27
  - [Ignore do projeto](project-ignore/SPEC.md): preservar o arquivo da raiz.
28
+ - [Ignore de clientes](client-ignore/SPEC.md): padrão local e exceções do projeto.
29
+ - [Ferramenta e versões](tool-release/SPEC.md): atualização segura e tags de release.
28
30
 
29
31
  Cada incremento vincula requisitos, critérios, testes e evidências locais.
30
32
 
@@ -0,0 +1,21 @@
1
+ ---
2
+ description: Exclusão local de clientes com escolha explícita do projeto.
3
+ ---
4
+
5
+ # Ignore de clientes
6
+
7
+ O Git não aplica `../` no `.gitignore` do hub às pastas irmãs. O kit usa
8
+ exclusões locais do Git e o projeto escolhe exceções no seu `.gitignore`.
9
+
10
+ ## Critérios de aceite
11
+
12
+ - AC-001: Pastas de clientes são ignoradas por padrão em repositórios Git,
13
+ sem modificar o `.gitignore` da raiz.
14
+ - AC-002: Uma exceção literal da raiz, como `!/.claude/`, permite versionar
15
+ o cliente e continua válida após instalação, atualização e sincronização.
16
+ - AC-003: Credenciais e artefatos continuam protegidos; exceções inseguras
17
+ são informadas antes de qualquer mudança.
18
+ - AC-004: Arquivos humanos e o índice Git, incluindo alterações preparadas,
19
+ são preservados. Arquivos já rastreados continuam rastreados.
20
+ - AC-005: Documentação e testes explicam e verificam precedência, limites,
21
+ clones, clientes compartilhados, cenários sem Git e sistemas suportados.
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Atualização segura do agent-browser e recuperação por versão.
3
+ ---
4
+
5
+ # Atualização de ferramenta e versões
6
+
7
+ O agent-browser passou a distribuir executáveis em pacotes opcionais por
8
+ plataforma. A atualização deve funcionar sem perder instalações existentes.
9
+
10
+ ## Critérios de aceite
11
+
12
+ - AC-001: O instalador aceita os layouts anterior e atual do agent-browser,
13
+ valida executáveis adequados à plataforma e comprova a versão instalada.
14
+ - AC-002: Caminhos inválidos ou incompletos são recusados antes da troca;
15
+ falhas preservam a instalação anterior e não atravessam links inseguros.
16
+ - AC-003: Cada release e pacote do fluxo aponta para a tag da mesma versão;
17
+ a documentação explica como consultar uma versão e simular seu uso.
18
+ - AC-004: Testes automatizados cobrem layouts e falhas, e a CI verifica Linux,
19
+ Windows e macOS na matriz configurada antes da publicação.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathbit/agentdevkit",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Shared skills, specifications and workflows for AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/install.mjs CHANGED
@@ -4,7 +4,7 @@ import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readF
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { synchronize, isReference } from '../.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
7
- import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack, indexContentMatches } from '../.pbai4devs/scripts/lib/gitignore.mjs';
7
+ import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, indexContentMatches } from '../.pbai4devs/scripts/lib/gitignore.mjs';
8
8
  import { nativePathBoundary } from '../.pbai4devs/scripts/lib/native-paths.mjs';
9
9
  import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, extractManagedBlock, managedBlockDigest, mergeManagedContent, stripManagedContent } from '../.pbai4devs/scripts/lib/managed-content.mjs';
10
10
  import { extractTomlManagedBlock, tomlManagedBlockDigest, mergeTomlContent, stripTomlContent } from '../.pbai4devs/scripts/lib/managed-toml.mjs';
@@ -698,16 +698,9 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
698
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.`);
699
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])));
700
700
  const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])].filter(path => !mixedPaths.has(path) && !overlapsPreserved(path));
701
- const hashes = {};
702
- for (const [path, digest] of Object.entries(previous?.files || {})) if (path !== '.gitignore' && (ownedOutputs.includes(path) || !path.startsWith('.pbai4devs/')) && !mixedPaths.has(path) && !overlapsPreserved(path) && !aliasedEntrypoint(path)) hashes[path] = digest;
703
- for (const path of ownedEntrypoints) { const current = read(target, path); if (current && (previous?.blocks[path] || derivedEntries[path] === hash(current))) hashes[path] = hash(current); }
704
- for (const path of Object.keys(previous?.blocks || {})) if (path.endsWith('/.gitignore') && adapterRoots.some(root => path === `${root}/.gitignore`)) {
705
- const current = read(target, path);
706
- if (current) { const source = current.toString('utf8'), block = extractBlock(source, '# BEGIN AGENT IGNORE', '# END AGENT IGNORE'); if (block && source.trim() === block.trim() && hash(block) === previous.blocks[path]) hashes[path] = hash(current); }
707
- }
708
- if (previous && !migration) hashes[statePath] = hash(read(target, statePath));
709
- const untrack = existsSync(target) ? planUntrack(target, hashes) : null;
710
- if (migration && untrack?.paths.length) fail('E_STATE', 'State migration would change the Git index; preserve and resolve tracked outputs first.');
701
+ // Ignore rules affect only untracked files. Never remove existing index
702
+ // entries on behalf of the project; staged bytes are checked separately.
703
+ const untrack = { root: target, paths: [], hashes: {}, index: '', conflicts: [] };
711
704
  const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
712
705
  const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
713
706
  conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
@@ -759,14 +752,12 @@ export function applyPlan(plan) {
759
752
  for (const change of plan.changes) if ((before.get(change.path) ? hash(before.get(change.path)) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
760
753
  for (const [path, content] of before) if (content) write(target, `${backup}/${path}`, content, beforeModes.get(path));
761
754
  const applied = [], removedDirectories = [];
762
- let untracked = false;
763
755
  const assertOriginal = path => {
764
756
  const file = safePath(target, path), info = stat(file), bytes = before.get(path);
765
757
  if (bytes === null ? info !== null : !info?.isFile() || !readFileSync(file).equals(bytes) || (info.mode & 0o777) !== beforeModes.get(path)) fail('E_CHANGED', `File changed before writing: ${path}`);
766
758
  };
767
759
  try {
768
760
  for (const change of plan.changes) assertOriginal(change.path);
769
- if (plan.untrack?.paths.length) { applyUntrack(plan.untrack); untracked = true; }
770
761
  for (const change of plan.changes) {
771
762
  assertOriginal(change.path);
772
763
  const file = safePath(target, change.path);
@@ -806,7 +797,7 @@ export function applyPlan(plan) {
806
797
  const changesFallback = plan.changes.some(change => change.path === '.gitignore');
807
798
  // Metadata is committed last; its writer rolls back partial writes itself.
808
799
  const ignored = applyLocalIgnore(changesFallback ? planLocalIgnore(target, { outputs: plan.ownedOutputs }) : plan.localIgnore);
809
- return { changed: Boolean(plan.changes.length || ignored.changed || untracked), backup: [...before.values()].some(Boolean) ? backup : null };
800
+ return { changed: Boolean(plan.changes.length || ignored.changed), backup: [...before.values()].some(Boolean) ? backup : null };
810
801
  } catch (error) {
811
802
  const rollbackErrors = [], preserved = [];
812
803
  for (const removed of [...removedDirectories].sort((a, b) => a.path.length - b.path.length)) {
@@ -847,7 +838,6 @@ export function applyPlan(plan) {
847
838
  if (preserved.length) {
848
839
  try { error.rollbackPreservedPaths = preserved; } catch { /* Preserve a exceção original. */ }
849
840
  }
850
- if (untracked) try { restoreUntrack(plan.untrack); } catch (failure) { rollbackErrors.push(failure); }
851
841
  if (rollbackErrors.length) throw Object.assign(new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error }), { code: error.code || 'E_OPERATION' });
852
842
  throw error;
853
843
  }