@pathbit/agentdevkit 0.0.6 → 0.0.8

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.
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Planeja em memória; somente os caminhos gerenciados pelo manifesto são alterados.
3
- import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, posix, resolve } from 'node:path';
5
5
  import { randomUUID, createHash } from 'node:crypto';
6
6
  import { requireNode, projectRoot, isMain } from '../lib/runtime.mjs';
7
7
  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
- import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
10
+ import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
11
11
 
12
12
  const marker = '<!-- gerado: sincronizar-agentes -->';
13
13
  const tomlMarker = '# gerado: sincronizar-agentes';
@@ -113,9 +113,13 @@ export function synchronize({ root = projectRoot, check = false, force = false,
113
113
  }
114
114
  const referenceFile = value => {
115
115
  const info = stat(safe(value));
116
- return Boolean(info?.isFile() && info.size <= 1024 * 1024 && isReference(read(absolute(value))));
116
+ if (!info?.isFile() || info.size > 1024 * 1024) return false;
117
+ const bytes = readFileSync(absolute(value)), text = normalize(bytes.toString('utf8'));
118
+ const recorded = installed?.files?.[value] === createHash('sha256').update(bytes).digest('hex');
119
+ const marked = text.split('\n').includes(marker) || text.startsWith(tomlMarker + '\n');
120
+ return isReference(text) && (marked || recorded);
117
121
  };
118
- const onlyReferences = value => scan(value).every(file => /\.(md|mdc|toml)$/.test(file) && referenceFile(file));
122
+ const onlyReferences = value => { const files = scan(value); return files.length > 0 && files.every(file => /\.(md|mdc|toml)$/.test(file) && referenceFile(file)); };
119
123
  const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
120
124
  const agents = new Map();
121
125
  const formats = new Map();
@@ -237,6 +241,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
237
241
  }
238
242
  function cleanScope(value) {
239
243
  if (!stat(safe(value))) return;
244
+ if (stat(safe(value)).isDirectory() && !scan(value).length) return;
240
245
  if (force && value.split('/')[1] === 'external-skills') planRemove(value, true);
241
246
  else if (onlyReferences(value)) planRemove(value);
242
247
  else if (force) planRemove(value, true);
@@ -410,16 +415,72 @@ export function synchronize({ root = projectRoot, check = false, force = false,
410
415
  const removals = topPaths(remove);
411
416
  const backups = topPaths(backup);
412
417
  const rootEntryChanges = [...rootEntries].filter(([name, content]) => !stat(absolute(name)) || readFileSync(absolute(name), 'utf8') !== content);
418
+ const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
419
+ const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
420
+ const configChanged = !configExists || configText !== finalConfig;
421
+ function capture(value) {
422
+ const file = safe(value), info = stat(file);
423
+ if (!info) return null;
424
+ if (info.isFile()) return { mode: info.mode & 0o777, data: readFileSync(file) };
425
+ if (!info.isDirectory()) fail(`Tipo de arquivo nao suportado: ${value}`);
426
+ return { mode: info.mode & 0o777, children: Object.fromEntries(readdirSync(file).sort(compare).map(name => [name, capture(`${value}/${name}`)])) };
427
+ }
428
+ function fingerprint(value) {
429
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
430
+ }
431
+ const transactionRoots = topPaths([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
432
+ const originals = new Map(transactionRoots.map(value => [value, capture(value)]));
433
+ const currentHashes = new Map([...originals].map(([value, content]) => [value, fingerprint(content)]));
434
+ const originalDirectories = new Set();
435
+ for (const value of transactionRoots) {
436
+ let parent = posix.dirname(value);
437
+ while (parent !== '.') { if (stat(safe(parent))?.isDirectory()) originalDirectories.add(parent); parent = posix.dirname(parent); }
438
+ }
439
+ const touched = new Set(), createdDirectories = new Set(), restoredDirectories = new Set();
440
+ const prunedDirectories = new Map();
441
+ let restoring = false;
413
442
  const summary = { changed: changed.length + rootEntryChanges.length, stale: removals.length, conflicts: conflicts.size, missing: missing.length, legacy: legacy.length, registries: registries.size, gitignores: ignoreFiles.size, manifests: configExists ? 0 : 1, localIgnores: localStatus.changed + localStatus.missing.length + localStatus.trackedOutputs.length };
414
443
  const dirty = Object.values(summary).some(Boolean);
415
444
  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.`);
416
445
  if (check) return { ...summary, ownedOutputs, exitCode: dirty ? 1 : 0 };
417
446
  if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
447
+ if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
448
+ if (localPlan) assertLocalIgnoreUnchanged(localPlan);
449
+ function ensureDirectory(value) {
450
+ let parent = posix.dirname(value);
451
+ const missing = [];
452
+ while (parent !== '.' && !stat(safe(parent))) { missing.push(parent); parent = posix.dirname(parent); }
453
+ for (const directory of missing.reverse()) {
454
+ mkdirSync(safe(directory)); createdDirectories.add(directory);
455
+ if (restoring) restoredDirectories.add(directory);
456
+ }
457
+ }
458
+ function mutate(value, action, text) {
459
+ const boundary = transactionRoots.find(root => value === root || value.startsWith(root + '/'));
460
+ if (!boundary || fingerprint(capture(boundary)) !== currentHashes.get(boundary)) fail(`Arquivo mudou apos planejar: ${value}`);
461
+ const beforeHash = currentHashes.get(boundary);
462
+ touched.add(boundary);
463
+ let failure;
464
+ try { action(); } catch (error) { failure = error; }
465
+ let afterHash;
466
+ try { afterHash = fingerprint(capture(boundary)); }
467
+ catch (error) { currentHashes.set(boundary, 'conteudo-concorrente'); throw failure || error; }
468
+ const matches = text === undefined || (text === null ? !stat(safe(value)) : stat(safe(value))?.isFile() && readFileSync(absolute(value)).equals(Buffer.from(text)));
469
+ if (!matches && afterHash !== beforeHash) {
470
+ currentHashes.set(boundary, 'conteudo-concorrente');
471
+ if (failure) throw failure;
472
+ fail(`Mudanca concorrente preservada: ${value}`);
473
+ }
474
+ currentHashes.set(boundary, afterHash);
475
+ if (failure) throw failure;
476
+ if (!matches) fail(`Arquivo nao corresponde a gravacao planejada: ${value}`);
477
+ }
418
478
  function atomic(value, text) {
419
479
  const target = safe(value);
420
- mkdirSync(dirname(target), { recursive: true });
480
+ const mode = stat(target)?.isFile() ? stat(target).mode & 0o777 : undefined;
481
+ ensureDirectory(value);
421
482
  const temporary = `${target}.sync.${randomUUID()}`;
422
- try { writeFileSync(temporary, text, { flag: 'wx' }); renameSync(temporary, target); }
483
+ try { writeFileSync(temporary, text, { flag: 'wx' }); if (mode !== undefined) chmodSync(temporary, mode); renameSync(temporary, target); }
423
484
  finally { rmSync(temporary, { force: true }); }
424
485
  }
425
486
  let backupRoot;
@@ -433,21 +494,60 @@ export function synchronize({ root = projectRoot, check = false, force = false,
433
494
  }
434
495
  output(`Backup local: ${posix.join('.agent-tests', backupRoot.slice(backupRoot.lastIndexOf('/') + 1).split('\\').at(-1))}`);
435
496
  }
436
- for (const value of removals) rmSync(safe(value), { recursive: true, force: true });
437
- for (const value of changed) atomic(value, expected.get(value));
438
- for (const [value, text] of registries) atomic(value, text);
439
- for (const [value, text] of ignoreFiles) atomic(value, text);
440
- for (const [value, text] of rootEntryChanges) atomic(value, text);
441
- if (localPlan) applyLocalIgnore(localPlan);
442
- function pruneEmpty(value) {
443
- if (!stat(safe(value))?.isDirectory()) return;
444
- for (const name of readdirSync(absolute(value))) if (stat(safe(`${value}/${name}`))?.isDirectory()) pruneEmpty(`${value}/${name}`);
445
- if (!readdirSync(absolute(value)).length) rmdirSync(absolute(value));
497
+ function restore(value, content) {
498
+ rmSync(safe(value), { recursive: true, force: true });
499
+ if (!content) return;
500
+ ensureDirectory(value);
501
+ if (content.data) writeFileSync(safe(value), content.data);
502
+ else {
503
+ mkdirSync(safe(value));
504
+ for (const [name, child] of Object.entries(content.children)) restore(`${value}/${name}`, child);
505
+ }
506
+ chmodSync(safe(value), content.mode);
507
+ }
508
+ try {
509
+ if (localPlan) assertLocalIgnoreUnchanged(localPlan);
510
+ for (const value of removals) mutate(value, () => rmSync(safe(value), { recursive: true, force: true }), null);
511
+ for (const value of changed) mutate(value, () => atomic(value, expected.get(value)), expected.get(value));
512
+ for (const [value, text] of registries) mutate(value, () => atomic(value, text), text);
513
+ for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
514
+ for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
515
+ if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
516
+ // Só os ancestrais de remoções conhecidas são candidatos à limpeza.
517
+ for (const value of removals) {
518
+ let parent = posix.dirname(value);
519
+ const boundary = value.split('/').slice(0, 2).join('/');
520
+ while (parent.startsWith(boundary + '/') || parent === boundary) {
521
+ const info = stat(safe(parent));
522
+ if (!info?.isDirectory() || readdirSync(absolute(parent)).length) break;
523
+ rmdirSync(safe(parent)); prunedDirectories.set(parent, info.mode & 0o777); parent = posix.dirname(parent);
524
+ }
525
+ }
526
+ if (localPlan) applyLocalIgnore(localPlan);
527
+ } catch (error) {
528
+ const failures = [];
529
+ restoring = true;
530
+ for (const value of [...touched].reverse()) {
531
+ try {
532
+ if (fingerprint(capture(value)) !== currentHashes.get(value)) fail(`Mudanca concorrente preservada: ${value}`);
533
+ restore(value, originals.get(value));
534
+ } catch (failure) { failures.push(failure); }
535
+ }
536
+ for (const [directory, mode] of [...prunedDirectories].sort(([left], [right]) => right.length - left.length)) {
537
+ try {
538
+ if (!stat(safe(directory))) ensureDirectory(`${directory}/.restore-probe`);
539
+ const info = stat(safe(directory));
540
+ if (!info?.isDirectory() || !restoredDirectories.has(directory) && (info.mode & 0o777) !== mode) fail(`Diretorio concorrente preservado: ${directory}`);
541
+ chmodSync(safe(directory), mode);
542
+ } catch (failure) { failures.push(failure); }
543
+ }
544
+ for (const directory of sorted(createdDirectories).sort((a, b) => b.length - a.length)) {
545
+ try { if (!originalDirectories.has(directory) && stat(safe(directory))?.isDirectory() && !readdirSync(absolute(directory)).length) rmdirSync(safe(directory)); }
546
+ catch (failure) { failures.push(failure); }
547
+ }
548
+ if (failures.length) throw new AggregateError([error, ...failures], `${error.message}; rollback incompleto: ${failures.map(failure => failure.message).join('; ')}`);
549
+ throw error;
446
550
  }
447
- for (const agent of agents.keys()) for (const kind of [...Object.keys(principals), 'instructions', 'prompts']) pruneEmpty(`${agent}/${kind}`);
448
- const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
449
- const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
450
- if (!configExists || configText !== finalConfig) atomic(config, finalConfig);
451
551
  return { ...summary, ownedOutputs, exitCode: 0, ...(backupRoot ? { backupRoot } : {}) };
452
552
  }
453
553
 
@@ -0,0 +1,145 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { syncBuiltinESMExports } from 'node:module';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { projectRoot } from '../../lib/runtime.mjs';
10
+ import { isReference, synchronize } from '../SCRIPT.mjs';
11
+
12
+ const hash = value => createHash('sha256').update(value).digest('hex');
13
+ const source = '.pbai4devs/skills/demo/SKILL.md';
14
+ const generated = '.claude/skills/demo/SKILL.md';
15
+ const human = '# Título\n\nLeia [manual](https://example.com/manual).\n';
16
+ function write(root, path, data) { fs.mkdirSync(dirname(join(root, path)), { recursive: true }); fs.writeFileSync(join(root, path), data); }
17
+ function fixture(t) {
18
+ const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'agentdevkit-ownership-')));
19
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
20
+ write(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\n');
21
+ write(root, source, '---\nname: demo\ndescription: Exemplo.\n---\n\n# Demo\n');
22
+ for (const name of ['agents.ignore', 'shared.ignore', 'local.ignore', 'clients.json']) write(root, `.pbai4devs/templates/gitignore/${name}`, fs.readFileSync(join(projectRoot, '.pbai4devs/templates/gitignore', name)));
23
+ const initialized = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
24
+ assert.equal(initialized.status, 0, initialized.stderr);
25
+ return root;
26
+ }
27
+ const run = (root, options = {}) => synchronize({ root, generateEntrypoints: false, localIgnores: false, output() {}, ...options });
28
+ function snapshot(root) {
29
+ const files = {};
30
+ function visit(path, prefix = '') {
31
+ for (const entry of fs.readdirSync(path, { withFileTypes: true })) {
32
+ if (entry.name === '.git') continue;
33
+ const next = prefix ? `${prefix}/${entry.name}` : entry.name;
34
+ if (entry.isDirectory()) { files[next] = 'directory'; visit(join(path, entry.name), next); }
35
+ else files[next] = hash(fs.readFileSync(join(path, entry.name)));
36
+ }
37
+ }
38
+ visit(root); return files;
39
+ }
40
+
41
+ test('texto humano com formato de referência nunca comprova propriedade', t => {
42
+ for (const path of [generated, '.claude/skills/human.md']) {
43
+ const root = fixture(t); write(root, path, human);
44
+ const before = snapshot(root);
45
+ assert.equal(isReference(human), true, 'O parser de formato continua independente da propriedade.');
46
+ assert.equal(run(root, { check: true }).exitCode, 1);
47
+ assert.throws(() => run(root), /--force/);
48
+ assert.deepEqual(snapshot(root), before);
49
+ assert.equal(fs.readFileSync(join(root, path), 'utf8'), human);
50
+ }
51
+ });
52
+
53
+ test('hash registrado comprova referência legada sem marcador e diretório vazio humano permanece', t => {
54
+ const root = fixture(t), stale = '.claude/skills/legacy.md';
55
+ write(root, stale, human);
56
+ write(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, clients: ['claude'], files: { [stale]: hash(human) }, blocks: {} }));
57
+ fs.mkdirSync(join(root, '.claude/skills/user-empty'), { recursive: true });
58
+ run(root);
59
+ assert.equal(fs.existsSync(join(root, stale)), false);
60
+ assert.equal(fs.existsSync(join(root, '.claude/skills/user-empty')), true);
61
+ assert.equal(run(root, { check: true }).exitCode, 0);
62
+ });
63
+
64
+ test('conflito de ignore é recusado antes de criar recursos ou catálogos', t => {
65
+ const root = fixture(t); write(root, '.gitignore', '!/.agent-tests/\n!/.agent-tests/**\n');
66
+ const before = snapshot(root), exclude = fs.readFileSync(join(root, '.git/info/exclude'));
67
+ assert.throws(() => run(root, { localIgnores: true, force: true }), /Regras humanas/);
68
+ assert.deepEqual(snapshot(root), before);
69
+ assert.deepEqual(fs.readFileSync(join(root, '.git/info/exclude')), exclude);
70
+ assert.equal(fs.existsSync(join(root, generated)), false);
71
+ assert.equal(fs.existsSync(join(root, '.pbai4devs/catalog.json')), false);
72
+ });
73
+
74
+ test('atualização de referência própria conserva permissões', t => {
75
+ const root = fixture(t); run(root);
76
+ fs.chmodSync(join(root, generated), 0o600);
77
+ const mode = fs.statSync(join(root, generated)).mode & 0o777;
78
+ write(root, source, '---\nname: demo\ndescription: Atualizado.\n---\n\n# Demo\n');
79
+ run(root);
80
+ assert.equal(fs.statSync(join(root, generated)).mode & 0o777, mode);
81
+ });
82
+
83
+ test('mudança concorrente de ignore após gravação reverte recursos e preserva a regra humana', t => {
84
+ const root = fixture(t); run(root);
85
+ write(root, source, '---\nname: demo\ndescription: Atualizado.\n---\n\n# Demo\n');
86
+ const before = snapshot(root), rename = fs.renameSync;
87
+ let injected = false;
88
+ t.mock.method(fs, 'renameSync', (from, to) => {
89
+ rename(from, to);
90
+ if (!injected && to === join(root, generated)) { injected = true; write(root, '.git/info/exclude', '# concurrent human change\n'); }
91
+ });
92
+ syncBuiltinESMExports();
93
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
94
+ assert.throws(() => run(root, { localIgnores: true }), /Exclusao alterada apos planejar/);
95
+ assert.equal(injected, true);
96
+ assert.deepEqual(snapshot(root), before);
97
+ assert.equal(fs.readFileSync(join(root, '.git/info/exclude'), 'utf8'), '# concurrent human change\n');
98
+ });
99
+
100
+ test('conteúdo humano concorrente não é apagado durante rollback', t => {
101
+ const root = fixture(t), rename = fs.renameSync;
102
+ let injected = false;
103
+ t.mock.method(fs, 'renameSync', (from, to) => {
104
+ rename(from, to);
105
+ if (!injected && to === join(root, generated)) { injected = true; write(root, generated, human); }
106
+ });
107
+ syncBuiltinESMExports();
108
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
109
+ assert.throws(() => run(root), /Mudanca concorrente preservada/);
110
+ assert.equal(injected, true);
111
+ assert.equal(fs.readFileSync(join(root, generated), 'utf8'), human);
112
+ });
113
+
114
+ test('conteúdo humano é preservado quando a escrita lança erro após promover o arquivo', t => {
115
+ const root = fixture(t), rename = fs.renameSync;
116
+ const failure = new Error('falha depois de promover');
117
+ let injected = false;
118
+ t.mock.method(fs, 'renameSync', (from, to) => {
119
+ rename(from, to);
120
+ if (!injected && to === join(root, generated)) { injected = true; write(root, generated, human); throw failure; }
121
+ });
122
+ syncBuiltinESMExports();
123
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
124
+ assert.throws(() => run(root), error => error instanceof AggregateError && error.errors.includes(failure));
125
+ assert.equal(injected, true);
126
+ assert.equal(fs.readFileSync(join(root, generated), 'utf8'), human);
127
+ });
128
+
129
+ test('rollback recria ancestral podado com suas permissões originais', { skip: process.platform === 'win32' }, t => {
130
+ const root = fixture(t); run(root);
131
+ const parent = join(root, '.claude/skills'); fs.chmodSync(parent, 0o700);
132
+ fs.rmSync(join(root, '.pbai4devs/skills/demo'), { recursive: true });
133
+ const before = snapshot(root), rename = fs.renameSync;
134
+ let injected = false;
135
+ t.mock.method(fs, 'renameSync', (from, to) => {
136
+ rename(from, to);
137
+ if (!injected && to === join(root, '.pbai4devs/catalog.json')) { injected = true; write(root, '.git/info/exclude', '# concurrent human change\n'); }
138
+ });
139
+ syncBuiltinESMExports();
140
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
141
+ assert.throws(() => run(root, { localIgnores: true }), /Exclusao alterada apos planejar/);
142
+ assert.equal(injected, true);
143
+ assert.deepEqual(snapshot(root), before);
144
+ assert.equal(fs.statSync(parent).mode & 0o777, 0o700);
145
+ });
@@ -1,10 +1,12 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { createHash } from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import { syncBuiltinESMExports } from 'node:module';
8
10
  import { projectRoot } from '../../lib/runtime.mjs';
9
11
  import { planLocalIgnore, applyLocalIgnore, checkLocalIgnore, planUntrack, applyUntrack, restoreUntrack, assertLocalIgnoreUnchanged } from '../../lib/gitignore.mjs';
10
12
 
@@ -129,6 +131,72 @@ test('raízes dedicadas são locais, enquanto configurações de GitHub e VS Cod
129
131
  assert.ok(tracked.includes('.github/settings.json')); assert.ok(tracked.includes('.vscode/settings.json'));
130
132
  });
131
133
 
134
+ test('artefatos vêm da política compartilhada e não entram na lista de outputs', t => {
135
+ const root = fixture(t); git(root, ['init', '-q']);
136
+ put(root, '.agent-tests/human.txt', 'evidência humana'); git(root, ['add', '-f', '.agent-tests/human.txt']);
137
+ const plan = planLocalIgnore(root, { outputs: ['AGENTS.md'] });
138
+ assert.ok(plan.artifactProbes.some(path => path.startsWith('.agent-tests/')));
139
+ assert.ok(plan.artifactProbes.some(path => path.startsWith('.agentdevkit-ignore-probes/')));
140
+ assert.ok(plan.paths.every(path => !plan.artifactProbes.includes(path)));
141
+ assert.ok(!plan.trackedOutputs.includes('.agent-tests/human.txt'));
142
+ applyLocalIgnore(plan);
143
+ for (const path of ['.agent-tests/x', 'TMP/x', 'nested/TeMp/x', '.PlAyWrIgHt-McP/x', 'nested/playwright-report/x', 'TEST-RESULTS/x', 'nested/TestResults/x', 'BLOB-REPORT/x']) {
144
+ assert.equal(git(root, ['check-ignore', '--no-index', path]).trim(), path);
145
+ }
146
+ assert.equal(git(root, ['ls-files', '.agent-tests/human.txt']).trim(), '.agent-tests/human.txt');
147
+ assert.equal(readFileSync(join(root, '.agent-tests/human.txt'), 'utf8'), 'evidência humana');
148
+ assert.equal(checkLocalIgnore(planLocalIgnore(root, { outputs: ['AGENTS.md'] })).ok, true);
149
+ });
150
+
151
+ test('regras de artefatos são limitadas ao projeto dentro do monorepo', t => {
152
+ const root = fixture(t); git(root, ['init', '-q']);
153
+ const child = join(root, 'apps', 'my project'); mkdirSync(child, { recursive: true });
154
+ applyLocalIgnore(planLocalIgnore(child, { outputs: ['AGENTS.md'] }));
155
+ for (const path of ['apps/my project/.agent-tests/x', 'apps/my project/nested/.Playwright-MCP/x', 'apps/my project/sub/test-results/x']) {
156
+ assert.equal(git(root, ['check-ignore', '--no-index', path]).trim(), path);
157
+ }
158
+ for (const path of ['apps/other/.agent-tests/x', 'apps/other/tmp/x', '.agent-tests/x']) {
159
+ const result = spawnSync('git', ['-C', root, 'check-ignore', '--no-index', path], { encoding: 'utf8' });
160
+ assert.equal(result.status, 1, path + result.stderr);
161
+ }
162
+ });
163
+
164
+ test('ignore global não mascara a ausência das regras locais de artefatos', t => {
165
+ const root = fixture(t), outside = fixture(t); git(root, ['init', '-q']);
166
+ put(outside, 'global.ignore', '*\n');
167
+ git(root, ['config', 'core.excludesFile', join(outside, 'global.ignore')]);
168
+ const plan = planLocalIgnore(root, { outputs: ['AGENTS.md'] });
169
+ assert.equal(plan.edits.length, 1);
170
+ const missing = checkLocalIgnore({ ...plan, edits: [] });
171
+ assert.ok(missing.missing.some(path => path.startsWith('.agent-tests/')));
172
+ applyLocalIgnore(plan);
173
+ assert.equal(checkLocalIgnore(planLocalIgnore(root, { outputs: ['AGENTS.md'] })).ok, true);
174
+ });
175
+
176
+ test('negação humana de artefatos bloqueia aplicação sem modificar regras ou índice', t => {
177
+ const root = fixture(t); git(root, ['init', '-q']);
178
+ const human = '!/.agent-tests/**\n'; put(root, '.gitignore', human);
179
+ const exclude = readFileSync(join(root, '.git/info/exclude'));
180
+ const plan = planLocalIgnore(root, { outputs: ['AGENTS.md'] });
181
+ assert.ok(checkLocalIgnore(plan).missing.some(path => path.startsWith('.agent-tests/')));
182
+ assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
183
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), exclude);
184
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), human);
185
+ });
186
+
187
+ test('probes reconhecem exceções humanas para nomes reais de Playwright', async t => {
188
+ for (const path of ['.playwright-mcp', 'playwright-report', 'playwright/.cache']) await t.test(path, subtest => {
189
+ const root = fixture(subtest); git(root, ['init', '-q']);
190
+ const prefixes = path.split('/').map((_part, index, parts) => '!/' + parts.slice(0, index + 1).join('/') + '/');
191
+ const human = [...prefixes, `!/${path}/**`, ''].join('\n'); put(root, '.gitignore', human);
192
+ const before = readFileSync(join(root, '.git/info/exclude'));
193
+ const plan = planLocalIgnore(root, { outputs: ['AGENTS.md'] });
194
+ assert.ok(checkLocalIgnore(plan).missing.some(probe => probe.startsWith(path + '/')));
195
+ assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
196
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), before);
197
+ });
198
+ });
199
+
132
200
  test('milhares de caminhos usam stdin e não ampliam metadata de área já ignorada', t => {
133
201
  const root = fixture(t); git(root, ['init', '-q']); put(root, '.gitignore', '/ignored/\n');
134
202
  const before = readFileSync(join(root, '.git/info/exclude'), 'utf8');
@@ -288,3 +356,145 @@ test('worktree usa a raiz nativa, exclude comum e seu próprio índice', t => {
288
356
  assert.equal(git(linked, ['show', ':generated.md']), 'gerado\n');
289
357
  assert.equal(git(root, ['ls-files', 'generated.md']), '');
290
358
  });
359
+
360
+ test('exceções com nomes arbitrários de artefatos não passam por amostragem', t => {
361
+ for (const [name, directory] of [['.playwright-custom', true], ['.playwright-report.json', false], ['playwright-e2e', true]]) {
362
+ const root = fixture(t); git(root, ['init', '-q']);
363
+ const human = `!/${name}${directory ? '/' : ''}\n${directory ? `!/${name}/**\n` : ''}`;
364
+ put(root, '.gitignore', human);
365
+ const before = readFileSync(join(root, '.git/info/exclude'));
366
+ const plan = planLocalIgnore(root);
367
+ assert.ok(checkLocalIgnore(plan).missing.some(path => path.startsWith(name)), name);
368
+ assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
369
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), human);
370
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), before);
371
+ }
372
+ });
373
+
374
+ test('arquivo real reaberto por glob é detectado também se surgir após o plano', t => {
375
+ const root = fixture(t); git(root, ['init', '-q']);
376
+ put(root, '.gitignore', '!/.playwright-report-*.json\n');
377
+ const plan = planLocalIgnore(root), path = '.playwright-report-real.json';
378
+ put(root, path, 'artefato local\n');
379
+ assert.ok(checkLocalIgnore(plan).missing.includes(path));
380
+ assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
381
+ assert.equal(readFileSync(join(root, path), 'utf8'), 'artefato local\n');
382
+ });
383
+
384
+ test('regras customizadas aninhadas continuam válidas e incluem pais ocultos', t => {
385
+ const root = fixture(t); git(root, ['init', '-q']);
386
+ const policy = readFileSync(join(projectRoot, '.pbai4devs/templates/gitignore/agents.ignore'), 'utf8');
387
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', `${policy}\n**/reports/cache*/\n`);
388
+ const path = '.hidden/reports/cache-custom/out.json';
389
+ put(root, path, 'local\n');
390
+ put(root, '.gitignore', '!/.hidden/reports/cache-custom/\n!/.hidden/reports/cache-custom/**\n');
391
+ const plan = planLocalIgnore(root);
392
+ assert.ok(checkLocalIgnore(plan).missing.includes(path));
393
+ assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
394
+ assert.equal(readFileSync(join(root, path), 'utf8'), 'local\n');
395
+ });
396
+
397
+ test('chaves literais em padrões Git não viram expansão de glob', t => {
398
+ const root = fixture(t); git(root, ['init', '-q']);
399
+ const policy = readFileSync(join(projectRoot, '.pbai4devs/templates/gitignore/agents.ignore'), 'utf8');
400
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', `${policy}\n**/cache{keep,archive}/\n`);
401
+ put(root, 'cachekeep/source.txt', 'código humano\n');
402
+ put(root, '.gitignore', '!cachekeep/**\n');
403
+ assert.doesNotThrow(() => applyLocalIgnore(planLocalIgnore(root)));
404
+ assert.equal(readFileSync(join(root, 'cachekeep/source.txt'), 'utf8'), 'código humano\n');
405
+ });
406
+
407
+ test('exceção nominal de um ignore ancestral é conferida no subprojeto', t => {
408
+ const root = fixture(t); git(root, ['init', '-q']);
409
+ put(root, '.gitignore', '!/modules/app/.playwright-personal/\n!/modules/app/.playwright-personal/**\n');
410
+ const child = join(root, 'modules/app'); mkdirSync(child, { recursive: true });
411
+ const plan = planLocalIgnore(child);
412
+ assert.ok(checkLocalIgnore(plan).missing.some(path => path.startsWith('.playwright-personal/')));
413
+ assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
414
+ });
415
+
416
+ test('escrita de exclusões preserva permissões e detecta alteração concorrente', { skip: process.platform === 'win32' }, t => {
417
+ const root = fixture(t); git(root, ['init', '-q']);
418
+ const exclude = join(root, '.git/info/exclude'); chmodSync(exclude, 0o600);
419
+ applyLocalIgnore(planLocalIgnore(root));
420
+ assert.equal(statSync(exclude).mode & 0o777, 0o600);
421
+ const changed = planLocalIgnore(root, { outputs: ['new-generated.md'] });
422
+ chmodSync(exclude, 0o640);
423
+ assert.throws(() => applyLocalIgnore(changed), /alterada apos planejar/);
424
+ assert.equal(statSync(exclude).mode & 0o777, 0o640);
425
+ });
426
+
427
+ test('fallback e rollback de exclusões mantêm o modo original', { skip: process.platform === 'win32' }, t => {
428
+ const root = fixture(t), ignore = join(root, '.gitignore');
429
+ put(root, '.gitignore', '# humano\n'); chmodSync(ignore, 0o600);
430
+ applyLocalIgnore(planLocalIgnore(root));
431
+ assert.equal(statSync(ignore).mode & 0o777, 0o600);
432
+ const original = readFileSync(ignore); git(root, ['init', '-q']);
433
+ const exclude = join(root, '.git/info/exclude'), before = readFileSync(exclude);
434
+ chmodSync(exclude, 0o600);
435
+ const plan = planLocalIgnore(root); assert.equal(plan.edits.length, 2);
436
+ Object.defineProperty(plan.edits[1], 'after', { get() { throw new Error('injected rollback failure'); } });
437
+ assert.throws(() => applyLocalIgnore(plan), /injected rollback failure/);
438
+ assert.deepEqual(readFileSync(exclude), before); assert.equal(statSync(exclude).mode & 0o777, 0o600);
439
+ assert.deepEqual(readFileSync(ignore), original); assert.equal(statSync(ignore).mode & 0o777, 0o600);
440
+ });
441
+
442
+ test('rollback restaura exclusão somente leitura com escrita atômica', { skip: process.platform === 'win32' }, t => {
443
+ const root = fixture(t), ignore = join(root, '.gitignore');
444
+ put(root, '.gitignore', '# humano\n'); chmodSync(ignore, 0o444);
445
+ applyLocalIgnore(planLocalIgnore(root));
446
+ const original = readFileSync(ignore); git(root, ['init', '-q']);
447
+ const exclude = join(root, '.git/info/exclude'), before = readFileSync(exclude);
448
+ chmodSync(exclude, 0o444);
449
+ const plan = planLocalIgnore(root);
450
+ Object.defineProperty(plan.edits[1], 'after', { get() { throw new Error('readonly rollback probe'); } });
451
+ assert.throws(() => applyLocalIgnore(plan), /readonly rollback probe/);
452
+ assert.deepEqual(readFileSync(exclude), before); assert.equal(statSync(exclude).mode & 0o777, 0o444);
453
+ assert.deepEqual(readFileSync(ignore), original); assert.equal(statSync(ignore).mode & 0o777, 0o444);
454
+ });
455
+
456
+ test('projeto aninhado não lê gitignore inacessível do projeto irmão', { skip: process.platform === 'win32' }, t => {
457
+ const root = fixture(t); git(root, ['init', '-q']);
458
+ const other = join(root, 'other/.gitignore'); put(root, 'other/.gitignore', 'tmp/\n');
459
+ git(root, ['add', 'other/.gitignore']); chmodSync(other, 0o000);
460
+ t.after(() => { try { chmodSync(other, 0o600); } catch {} });
461
+ const child = join(root, 'app'); mkdirSync(child);
462
+ const plan = planLocalIgnore(child);
463
+ applyLocalIgnore(plan);
464
+ assert.equal(checkLocalIgnore(planLocalIgnore(child)).ok, true);
465
+ });
466
+
467
+ test('falha no cleanup após promoção ainda reverte a exclusão publicada', t => {
468
+ const root = fixture(t); git(root, ['init', '-q']);
469
+ const file = join(root, '.git/info/exclude'), original = readFileSync(file);
470
+ const plan = planLocalIgnore(root), remove = fs.rmSync;
471
+ const injected = new Error('published cleanup failure'); let failed = false;
472
+ try {
473
+ fs.rmSync = (path, options) => {
474
+ if (!failed && String(path).startsWith(`${file}.agentdevkit-`) && !existsSync(path)) { failed = true; throw injected; }
475
+ return remove(path, options);
476
+ };
477
+ syncBuiltinESMExports();
478
+ assert.throws(() => applyLocalIgnore(plan), error => error === injected);
479
+ } finally { fs.rmSync = remove; syncBuiltinESMExports(); }
480
+ assert.equal(failed, true);
481
+ assert.deepEqual(readFileSync(file), original);
482
+ });
483
+
484
+ test('falha irrecuperável de rollback informa também o erro original', t => {
485
+ const root = fixture(t); git(root, ['init', '-q']);
486
+ const file = join(root, '.git/info/exclude'), plan = planLocalIgnore(root);
487
+ const remove = fs.rmSync, rename = fs.renameSync;
488
+ const original = new Error('original cleanup failure'), rollback = new Error('restore unavailable');
489
+ let failed = false;
490
+ try {
491
+ fs.rmSync = (path, options) => {
492
+ if (!failed && String(path).startsWith(`${file}.agentdevkit-`) && !existsSync(path)) { failed = true; throw original; }
493
+ return remove(path, options);
494
+ };
495
+ fs.renameSync = (from, to) => { if (failed && to === file) throw rollback; return rename(from, to); };
496
+ syncBuiltinESMExports();
497
+ assert.throws(() => applyLocalIgnore(plan), error => error instanceof AggregateError
498
+ && error.cause === original && error.errors.includes(rollback));
499
+ } finally { fs.rmSync = remove; fs.renameSync = rename; syncBuiltinESMExports(); }
500
+ });
@@ -10,6 +10,7 @@ import { buildCatalog } from '../../lib/catalog.mjs';
10
10
  import { toolWrapper } from '../../preparar-ferramentas/SCRIPT.mjs';
11
11
  import { mergeGitignore, ignoreBegin, ignoreEnd, validateIgnoreClients } from '../../lib/gitignore.mjs';
12
12
  import './LOCAL_IGNORE.mjs';
13
+ import './HUMAN_OWNERSHIP.mjs';
13
14
  import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
14
15
 
15
16
  const project = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
@@ -58,7 +59,7 @@ function fixture(t, minimal = false) {
58
59
  write(root, '.pbai4devs/agents/revisor/kimi-code.yaml', '---\nname: revisor\ntools: [Read, Grep, Glob]\n---\n');
59
60
  write(root, '.pbai4devs/agents/revisor/opencode.yaml', '---\nmode: subagent\npermission:\n edit: deny\n---\n');
60
61
  write(root, '.pbai4devs/adapters/antigravity/agents/revisor.md', pointer('../../../agents/revisor/AGENT.md', 'Revisor'));
61
- write(root, '.opencode/agents/revisor/AGENT.md', '---\nmode: subagent\npermission:\n edit: deny\n---\n\n# Revisor\n\nLeia e siga [origem](../../../.pbai4devs/agents/revisor/AGENT.md).\n');
62
+ write(root, '.opencode/agents/revisor/AGENT.md', `---\nmode: subagent\npermission:\n edit: deny\n---\n\n# Revisor\n\n${marker}\nLeia e siga [origem](../../../.pbai4devs/agents/revisor/AGENT.md).\n`);
62
63
  write(root, '.github/workflows/ci.yml', 'workflow-preservado\n');
63
64
  write(root, '.vscode/settings.json', '{"fixture":"config-preservada"}\n');
64
65
  write(root, '.vscode/extensions.json', '{"recommendations":[]}\n');
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.8 — 2026-11-11
8
+
9
+ - Preservação de arquivos humanos que parecem apontamentos gerados.
10
+ - Conflitos de ignore recusados antes das gravações e rollback revisado.
11
+ - Permissões mantidas em atualização, backup e restauração de arquivos.
12
+ - Caminhos simbólicos de ferramentas bloqueados antes de escrita externa.
13
+ - Argumentos de ferramentas preservados após `--`; ajuda sem projeto válido.
14
+ - Verificação de artefatos com nomes próprios, arquivos e regras aninhadas.
15
+
16
+ ## 0.0.7 — 2026-11-11
17
+
18
+ - Artefatos de testes e pastas temporárias protegidos em projetos existentes.
19
+ - Regras locais respeitam o escopo do projeto e as exceções do usuário.
20
+ - Teste opt-in em clones reais, com commits fixos e comparação de arquivos.
21
+ - Relato explícito de bloqueios por links simbólicos preexistentes.
22
+
7
23
  ## 0.0.6 — 2026-11-11
8
24
 
9
25
  - Metadados npm com autoria PathBit, palavras-chave e licença MIT.
package/README.en.md CHANGED
@@ -34,7 +34,7 @@ Then open the project in your coding agent and ask:
34
34
 
35
35
  See [installation](docs/guides/installation.md) for Windows, missing Node, previews
36
36
  and updates. Install and update through the [public npm package][packages]; the
37
- [v0.0.6 release][release] retains artifacts and checksums for verification.
37
+ [v0.0.8 release][release] retains artifacts and checksums for verification.
38
38
  No global installation is required.
39
39
  The CLI supports `pt`, `en` and `es`; it saves your selection per project.
40
40
 
@@ -78,7 +78,7 @@ forms. See [how to participate](docs/maintainers/community.md).
78
78
  agent to read `AGENTS.md`.
79
79
 
80
80
  [clients]: .pbai4devs/COMPATIBILIDADE.md
81
- [release]: https://github.com/pathbit/AgentDevKit/releases/tag/v0.0.6
81
+ [release]: https://github.com/pathbit/AgentDevKit/releases/tag/v0.0.8
82
82
  [npm-package]: https://www.npmjs.com/package/@pathbit/agentdevkit
83
83
  [npm-badge]: https://img.shields.io/npm/v/%40pathbit%2Fagentdevkit
84
84
  [license-badge]: https://img.shields.io/badge/license-MIT-blue