@pathbit/agentdevkit 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.pbai4devs/COMPATIBILIDADE.md +4 -2
  2. package/.pbai4devs/INSTRUCTIONS.md +4 -0
  3. package/.pbai4devs/commands/sincronizar/COMMAND.md +4 -3
  4. package/.pbai4devs/rules/documentacao/RULE.md +4 -0
  5. package/.pbai4devs/rules/qualidade/RULE.md +4 -2
  6. package/.pbai4devs/scripts/README.md +3 -3
  7. package/.pbai4devs/scripts/inspecionar-projeto/README.md +31 -0
  8. package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +142 -1
  9. package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +168 -1
  10. package/.pbai4devs/scripts/lib/gitignore.mjs +84 -15
  11. package/.pbai4devs/scripts/lib/native-paths.mjs +35 -0
  12. package/.pbai4devs/scripts/preparar-ferramentas/SCRIPT.mjs +41 -20
  13. package/.pbai4devs/scripts/preparar-ferramentas/tests/TEST.mjs +81 -0
  14. package/.pbai4devs/scripts/sincronizar-agentes/README.md +3 -0
  15. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +193 -49
  16. package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +190 -0
  17. package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +151 -0
  18. package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +145 -1
  19. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +30 -29
  20. package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +56 -8
  21. package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +92 -1
  22. package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +2 -0
  23. package/.pbai4devs/skills/produto/criar-prd/SKILL.md +2 -0
  24. package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +2 -0
  25. package/.pbai4devs/skills/projeto/atualizar-documentacao/SKILL.md +18 -4
  26. package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +2 -0
  27. package/.pbai4devs/skills/projeto/criar-recurso/SKILL.md +1 -0
  28. package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +3 -2
  29. package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -0
  30. package/.pbai4devs/skills/specs/gerenciar-roadmap/SKILL.md +2 -1
  31. package/.pbai4devs/templates/README.md +4 -1
  32. package/.pbai4devs/templates/configuracao/TEMPLATE.md +6 -0
  33. package/CHANGELOG.md +19 -0
  34. package/README.en.md +3 -2
  35. package/README.es.md +3 -2
  36. package/README.md +3 -2
  37. package/docs/README.md +1 -0
  38. package/docs/guides/README.md +1 -0
  39. package/docs/guides/cli.md +11 -0
  40. package/docs/guides/documentation.md +63 -0
  41. package/docs/guides/installation.md +18 -5
  42. package/docs/guides/resources.md +6 -5
  43. package/docs/guides/tools-and-testing.md +2 -0
  44. package/docs/guides/troubleshooting.md +6 -6
  45. package/docs/guides/workflow.md +2 -0
  46. package/docs/maintainers/packages.md +1 -1
  47. package/docs/product/design.md +12 -0
  48. package/docs/product/prd.md +4 -1
  49. package/docs/product/roadmap.md +4 -0
  50. package/docs/specs/README.md +2 -0
  51. package/docs/specs/v0.0.8/README.md +10 -0
  52. package/docs/specs/v0.0.8/SPEC.md +62 -0
  53. package/docs/specs/v0.0.9/README.md +9 -0
  54. package/docs/specs/v0.0.9/SPEC.md +68 -0
  55. package/package.json +3 -2
  56. package/src/cli.mjs +13 -4
  57. package/src/i18n.mjs +3 -0
  58. package/src/install.mjs +145 -38
@@ -0,0 +1,151 @@
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
+ const result = run(root);
48
+ assert.ok(result.preserved.some(item => item.path === path));
49
+ assert.ok(!result.ownedOutputs.includes(path));
50
+ assert.equal(fs.readFileSync(join(root, path), 'utf8'), human);
51
+ assert.equal(snapshot(root)[path], before[path]);
52
+ assert.equal(run(root, { check: true }).exitCode, 0);
53
+ const stable = snapshot(root);
54
+ run(root, { force: true });
55
+ assert.deepEqual(snapshot(root), stable);
56
+ }
57
+ });
58
+
59
+ test('hash registrado comprova referência legada sem marcador e diretório vazio humano permanece', t => {
60
+ const root = fixture(t), stale = '.claude/skills/legacy.md';
61
+ write(root, stale, human);
62
+ write(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, clients: ['claude'], files: { [stale]: hash(human) }, blocks: {} }));
63
+ fs.mkdirSync(join(root, '.claude/skills/user-empty'), { recursive: true });
64
+ run(root);
65
+ assert.equal(fs.existsSync(join(root, stale)), false);
66
+ assert.equal(fs.existsSync(join(root, '.claude/skills/user-empty')), true);
67
+ assert.equal(run(root, { check: true }).exitCode, 0);
68
+ });
69
+
70
+ test('conflito de ignore é recusado antes de criar recursos ou catálogos', t => {
71
+ const root = fixture(t); write(root, '.gitignore', '!/.agent-tests/\n!/.agent-tests/**\n');
72
+ const before = snapshot(root), exclude = fs.readFileSync(join(root, '.git/info/exclude'));
73
+ assert.throws(() => run(root, { localIgnores: true, force: true }), /Regras humanas/);
74
+ assert.deepEqual(snapshot(root), before);
75
+ assert.deepEqual(fs.readFileSync(join(root, '.git/info/exclude')), exclude);
76
+ assert.equal(fs.existsSync(join(root, generated)), false);
77
+ assert.equal(fs.existsSync(join(root, '.pbai4devs/catalog.json')), false);
78
+ });
79
+
80
+ test('atualização de referência própria conserva permissões', t => {
81
+ const root = fixture(t); run(root);
82
+ fs.chmodSync(join(root, generated), 0o600);
83
+ const mode = fs.statSync(join(root, generated)).mode & 0o777;
84
+ write(root, source, '---\nname: demo\ndescription: Atualizado.\n---\n\n# Demo\n');
85
+ run(root);
86
+ assert.equal(fs.statSync(join(root, generated)).mode & 0o777, mode);
87
+ });
88
+
89
+ test('mudança concorrente de ignore após gravação reverte recursos e preserva a regra humana', t => {
90
+ const root = fixture(t); run(root);
91
+ write(root, source, '---\nname: demo\ndescription: Atualizado.\n---\n\n# Demo\n');
92
+ const before = snapshot(root), rename = fs.renameSync;
93
+ let injected = false;
94
+ t.mock.method(fs, 'renameSync', (from, to) => {
95
+ rename(from, to);
96
+ if (!injected && to === join(root, generated)) { injected = true; write(root, '.git/info/exclude', '# concurrent human change\n'); }
97
+ });
98
+ syncBuiltinESMExports();
99
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
100
+ assert.throws(() => run(root, { localIgnores: true }), /Exclusao alterada apos planejar/);
101
+ assert.equal(injected, true);
102
+ assert.deepEqual(snapshot(root), before);
103
+ assert.equal(fs.readFileSync(join(root, '.git/info/exclude'), 'utf8'), '# concurrent human change\n');
104
+ });
105
+
106
+ test('conteúdo humano concorrente não é apagado durante rollback', t => {
107
+ const root = fixture(t), rename = fs.renameSync;
108
+ let injected = false;
109
+ t.mock.method(fs, 'renameSync', (from, to) => {
110
+ rename(from, to);
111
+ if (!injected && to === join(root, generated)) { injected = true; write(root, generated, human); }
112
+ });
113
+ syncBuiltinESMExports();
114
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
115
+ assert.throws(() => run(root), /Mudanca concorrente preservada/);
116
+ assert.equal(injected, true);
117
+ assert.equal(fs.readFileSync(join(root, generated), 'utf8'), human);
118
+ });
119
+
120
+ test('conteúdo humano é preservado quando a escrita lança erro após promover o arquivo', t => {
121
+ const root = fixture(t), rename = fs.renameSync;
122
+ const failure = new Error('falha depois de promover');
123
+ let injected = false;
124
+ t.mock.method(fs, 'renameSync', (from, to) => {
125
+ rename(from, to);
126
+ if (!injected && to === join(root, generated)) { injected = true; write(root, generated, human); throw failure; }
127
+ });
128
+ syncBuiltinESMExports();
129
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
130
+ assert.throws(() => run(root), error => error instanceof AggregateError && error.errors.includes(failure));
131
+ assert.equal(injected, true);
132
+ assert.equal(fs.readFileSync(join(root, generated), 'utf8'), human);
133
+ });
134
+
135
+ test('rollback recria ancestral podado com suas permissões originais', { skip: process.platform === 'win32' }, t => {
136
+ const root = fixture(t); run(root);
137
+ const parent = join(root, '.claude/skills'); fs.chmodSync(parent, 0o700);
138
+ fs.rmSync(join(root, '.pbai4devs/skills/demo'), { recursive: true });
139
+ const before = snapshot(root), rename = fs.renameSync;
140
+ let injected = false;
141
+ t.mock.method(fs, 'renameSync', (from, to) => {
142
+ rename(from, to);
143
+ if (!injected && to === join(root, '.pbai4devs/catalog.json')) { injected = true; write(root, '.git/info/exclude', '# concurrent human change\n'); }
144
+ });
145
+ syncBuiltinESMExports();
146
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
147
+ assert.throws(() => run(root, { localIgnores: true }), /Exclusao alterada apos planejar/);
148
+ assert.equal(injected, true);
149
+ assert.deepEqual(snapshot(root), before);
150
+ assert.equal(fs.statSync(parent).mode & 0o777, 0o700);
151
+ });
@@ -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
 
@@ -354,3 +356,145 @@ test('worktree usa a raiz nativa, exclude comum e seu próprio índice', t => {
354
356
  assert.equal(git(linked, ['show', ':generated.md']), 'gerado\n');
355
357
  assert.equal(git(root, ['ls-files', 'generated.md']), '');
356
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,8 @@ 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';
14
+ import './COEXISTENCE.mjs';
13
15
  import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
14
16
 
15
17
  const project = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
@@ -20,6 +22,7 @@ const source = '.pbai4devs/skills/projeto/demo/SKILL.md';
20
22
  const read = (root, path) => readFileSync(join(root, path), 'utf8');
21
23
  const write = (root, path, content) => { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), content); };
22
24
  const pointer = (origin = 'origem.md', title = 'Referencia') => `---\ndescription: Referencia.\n---\n\n# ${title}\n\n${marker}\nLeia e siga [origem](${origin}).\n`;
25
+ const recordOwned = (root, paths) => write(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, files: Object.fromEntries(paths.map(path => [path, createHash('sha256').update(readFileSync(join(root, path))).digest('hex')])), blocks: {} }));
23
26
  const toolConfig = (name = 'demo') => ({ name, description: `Ferramenta ${name}.`, group: 'base', source: `https://github.com/fixture/${name}`, download: { provider: 'npm', package: name, registry: 'https://registry.npmjs.org/', repository: `fixture/${name}`, channel: 'latest' }, platforms: [`${process.platform}-${process.arch}`], scripts: { entry: 'SCRIPT.mjs' }, probe: ['--version'] });
24
27
  const externalConfig = () => ({ schemaVersion: 1, allowlist: ['fixture/skills'], skills: [{ id: 'external-demo', repository: 'fixture/skills', commit: 'a'.repeat(40), path: 'skills/demo/SKILL.md', name: 'external-demo', license: 'MIT', use: 'Revisar exemplo externo.', status: 'reviewed', notes: 'Sem execução automática.' }] });
25
28
  const run = (root, options = {}) => synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {}, ...options });
@@ -58,7 +61,7 @@ function fixture(t, minimal = false) {
58
61
  write(root, '.pbai4devs/agents/revisor/kimi-code.yaml', '---\nname: revisor\ntools: [Read, Grep, Glob]\n---\n');
59
62
  write(root, '.pbai4devs/agents/revisor/opencode.yaml', '---\nmode: subagent\npermission:\n edit: deny\n---\n');
60
63
  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');
64
+ 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
65
  write(root, '.github/workflows/ci.yml', 'workflow-preservado\n');
63
66
  write(root, '.vscode/settings.json', '{"fixture":"config-preservada"}\n');
64
67
  write(root, '.vscode/extensions.json', '{"recommendations":[]}\n');
@@ -333,7 +336,7 @@ test('registros são derivados, determinísticos e --check nunca os corrige', t
333
336
  assertCheckReadonly(empty, {}, 0);
334
337
  });
335
338
 
336
- test('skills externas baixadas não entram nos índices; --force salva e limpa cópias indevidas', t => {
339
+ test('skills externas baixadas não entram nos índices; cópias humanas permanecem intactas', t => {
337
340
  const root = fixture(t);
338
341
  write(root, '.pbai4devs/external-skills/README.md', '# Catálogo externo\n');
339
342
  write(root, '.pbai4devs/external-skills/CATALOG.json', JSON.stringify(externalConfig()));
@@ -347,38 +350,34 @@ test('skills externas baixadas não entram nos índices; --force salva e limpa c
347
350
  write(root, '.codex/external-skills/.local/upstream-0/SKILL.md', '# Cópia indevida\n');
348
351
  write(root, '.claude/external-skills/README.md', pointer('../../.pbai4devs/external-skills/README.md'));
349
352
  assertCheckReadonly(root);
350
- rejected(root, {}, /--force/);
351
353
  const result = run(root, { force: true });
352
- for (const client of ['.codex', '.claude']) {
353
- assert.equal(existsSync(join(root, client, 'external-skills')), false);
354
- assert.ok(existsSync(join(result.backupRoot, client, 'external-skills')));
355
- }
354
+ assert.equal(existsSync(join(root, '.claude/external-skills')), false);
355
+ assert.ok(result.preserved.some(item => item.path === '.codex/external-skills'));
356
356
  assert.equal(read(root, '.pbai4devs/external-skills/.local/upstream-0/SKILL.md'), sourceBefore);
357
- assert.equal(read(result.backupRoot, '.codex/external-skills/.local/upstream-0/SKILL.md'), '# Cópia indevida\n');
357
+ assert.equal(read(root, '.codex/external-skills/.local/upstream-0/SKILL.md'), '# Cópia indevida\n');
358
358
  assertCheckReadonly(root, {}, 0);
359
359
  });
360
360
 
361
- test('--force salva conteúdo próprio, repara ancestrais e preserva arquivos fora do escopo', t => {
361
+ test('--force repara conteúdo registrado e preserva recursos humanos sem propriedade', t => {
362
362
  const root = fixture(t);
363
363
  run(root);
364
+ recordOwned(root, ['.claude/skills/projeto/demo/SKILL.md', '.kimi-code/agents/revisor.md']);
364
365
  write(root, '.claude/scripts/local.sh', 'script humano\n');
365
366
  write(root, '.claude/tools/program.exe', Buffer.from([1, 2, 3]));
366
367
  write(root, '.claude/docs/plain.md', '# Documento auxiliar\n');
367
368
  write(root, '.claude/skills/local.bin', 'custom\n');
368
369
  write(root, '.claude/skills/projeto/demo/SKILL.md', 'corrompido\n');
369
370
  write(root, '.kimi-code/agents/revisor.md', 'corrompido\n');
370
- rmSync(join(root, '.codex/skills/outro'), { recursive: true });
371
- write(root, '.codex/skills/outro', 'ancestral arquivo\n');
372
371
  write(root, '.github/prompts/edited.prompt.md', pointer() + '\nRevisar [manual](bar).\n');
373
372
  assertCheckReadonly(root);
374
373
  assertCheckReadonly(root, { force: true });
375
374
  rejected(root, {}, /--force/);
376
375
  const result = run(root, { force: true });
377
376
  assert.ok(result.backupRoot.startsWith(join(root, '.agent-tests')));
378
- for (const path of ['.claude/scripts/local.sh', '.claude/tools/program.exe', '.claude/docs/plain.md', '.claude/skills/local.bin', '.claude/skills/projeto/demo/SKILL.md', '.kimi-code/agents/revisor.md', '.codex/skills/outro', '.github/prompts/edited.prompt.md']) assert.ok(existsSync(join(result.backupRoot, path)), `Backup ausente: ${path}`);
379
- assert.deepEqual(readFileSync(join(result.backupRoot, '.claude/tools/program.exe')), Buffer.from([1, 2, 3]));
380
- assert.equal(existsSync(join(root, '.claude/scripts')), false);
381
- assert.equal(existsSync(join(root, '.claude/skills/local.bin')), false);
377
+ for (const path of ['.claude/skills/projeto/demo/SKILL.md', '.kimi-code/agents/revisor.md', '.github/prompts/edited.prompt.md']) assert.ok(existsSync(join(result.backupRoot, path)), `Backup ausente: ${path}`);
378
+ assert.deepEqual(readFileSync(join(root, '.claude/tools/program.exe')), Buffer.from([1, 2, 3]));
379
+ assert.equal(read(root, '.claude/scripts/local.sh'), 'script humano\n');
380
+ assert.equal(read(root, '.claude/skills/local.bin'), 'custom\n');
382
381
  assert.equal(existsSync(join(root, '.github/prompts/edited.prompt.md')), false);
383
382
  assert.match(read(root, '.codex/skills/outro/SKILL.md'), /name: outro/);
384
383
  assert.match(read(root, '.github/workflows/ci.yml'), /workflow-preservado/);
@@ -465,8 +464,8 @@ test('metadados incompletos, ausentes e com corpo são rejeitados antes de alter
465
464
  rejected(root, { force: true }, /Titulo/);
466
465
  });
467
466
 
468
- test('symlinks são rejeitados sem efeitos quando o sistema permite criá-los', t => {
469
- for (const location of ['.claude/tools', '.pbai4devs/skills/projeto/demo/external.md', '.pbai4devs/skills.json', '.pbai4devs/espelhos.conf']) {
467
+ test('symlinks canônicos são rejeitados sem efeitos quando o sistema permite criá-los', t => {
468
+ for (const location of ['.pbai4devs/skills/projeto/demo/external.md', '.pbai4devs/skills.json', '.pbai4devs/espelhos.conf']) {
470
469
  const root = fixture(t);
471
470
  rmSync(join(root, location), { recursive: true, force: true });
472
471
  try { symlinkSync(join(root, 'missing-target'), join(root, location)); }
@@ -499,6 +498,8 @@ test('registros não regulares são rejeitados sem efeitos', t => {
499
498
  test('backup recusa raiz simbólica antes de remover conteúdo próprio', t => {
500
499
  const root = fixture(t);
501
500
  run(root);
501
+ recordOwned(root, ['.claude/skills/projeto/demo/SKILL.md']);
502
+ write(root, '.claude/skills/projeto/demo/SKILL.md', 'registrado alterado\n');
502
503
  write(root, '.claude/scripts/own.sh', 'preservar\n');
503
504
  const outside = join(root, 'outside');
504
505
  mkdirSync(outside);
@@ -823,33 +824,33 @@ test('manifestos Antigravity acompanham skills, regras e agentes agrupados', t =
823
824
  write(root, '.pbai4devs/agents/review/security/AGENT.md', '---\nname: security\ndescription: Perfil.\n---\n\n# Perfil\n');
824
825
  write(root, '.pbai4devs/espelhos.conf', 'agente|.agents|agrupado\ndescoberta|.agents|antigravity\n');
825
826
  const before = snapshot(root);
826
- assert.equal(run(root, { check: true }).exitCode, 1);
827
+ assert.equal(run(root, { check: true, generateEntrypoints: true }).exitCode, 1);
827
828
  assert.deepEqual(snapshot(root), before);
828
- run(root);
829
+ run(root, { generateEntrypoints: true });
829
830
  assert.deepEqual(JSON.parse(read(root, '.agents/skills.json')).entries, [{ path: '.agents/skills/projeto/demo' }]);
830
831
  assert.deepEqual(JSON.parse(read(root, '.agents/rules.json')).entries, [{ path: '.agents/rules/projeto/style/RULE.md' }]);
831
832
  assert.deepEqual(JSON.parse(read(root, '.agents/agents.json')).entries, [{ path: '.agents/agents/review/security/AGENT.md' }]);
832
833
  write(root, '.pbai4devs/skills/projeto/new-skill/SKILL.md', skill.replace('name: demo', 'name: new-skill'));
833
- run(root);
834
+ run(root, { generateEntrypoints: true });
834
835
  assert.equal(JSON.parse(read(root, '.agents/skills.json')).entries.length, 2);
835
836
  rmSync(join(root, '.pbai4devs/skills/projeto/demo'), { recursive: true });
836
- run(root);
837
+ run(root, { generateEntrypoints: true });
837
838
  assert.deepEqual(JSON.parse(read(root, '.agents/skills.json')).entries, [{ path: '.agents/skills/projeto/new-skill' }]);
838
839
  assert.equal(existsSync(join(root, '.agents/skills/projeto/demo')), false);
839
- assertCheckReadonly(root, {}, 0);
840
+ assertCheckReadonly(root, { generateEntrypoints: true }, 0);
840
841
  });
841
842
 
842
- test('manifesto de descoberta customizado exige force e recebe backup', t => {
843
+ test('manifesto de descoberta humano permanece intacto mesmo com force', t => {
843
844
  const root = fixture(t, true);
844
845
  write(root, source, skill);
845
846
  write(root, '.pbai4devs/espelhos.conf', 'agente|.agents|agrupado\ndescoberta|.agents|antigravity\n');
846
- run(root);
847
- const custom = '{"entries":[{"path":"custom/skill"}]}\n';
847
+ const custom = '{"entries":[{"path":".agents/skills/custom"}]}\n';
848
848
  write(root, '.agents/skills.json', custom);
849
- rejected(root, {}, /force/);
850
- const result = run(root, { force: true });
851
- assert.equal(readFileSync(join(result.backupRoot, '.agents/skills.json'), 'utf8'), custom);
852
- assertCheckReadonly(root, {}, 0);
849
+ const result = run(root, { force: true, generateEntrypoints: true });
850
+ assert.equal(read(root, '.agents/skills.json'), custom);
851
+ assert.ok(result.preserved.some(item => item.path === '.agents/skills.json'));
852
+ assert.ok(!result.ownedOutputs.includes('.agents/skills.json'));
853
+ assertCheckReadonly(root, { generateEntrypoints: true }, 0);
853
854
  });
854
855
 
855
856
  test('formatos desconhecidos ou fora do cliente falham antes de mutar', t => {
@@ -20,6 +20,39 @@ function checkedStat(root, location) {
20
20
  return item;
21
21
  }
22
22
 
23
+ function checkedPath(root, path) {
24
+ if (typeof path !== 'string' || !path || isAbsolute(path) || /[\\\x00-\x1f\x7f:]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) fail(`Caminho gerenciado invalido: ${path}`);
25
+ let location = root;
26
+ for (const part of path.split('/')) { location = join(location, part); checkedStat(root, location); }
27
+ return location;
28
+ }
29
+
30
+ function ownership(root) {
31
+ const file = checkedPath(root, '.pbai4devs/estado/agentdevkit.json');
32
+ if (!checkedStat(root, file)) return null;
33
+ const readState = location => {
34
+ try { return JSON.parse(readFileSync(location, 'utf8')); }
35
+ catch { fail(`Estado invalido: ${normalize(relative(root, location))}`); }
36
+ };
37
+ const state = readState(file);
38
+ const validMap = value => value && typeof value === 'object' && !Array.isArray(value)
39
+ && Object.values(value).every(hash => typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash));
40
+ if (state?.schemaVersion !== 1 || !validMap(state.files) || !validMap(state.blocks)) fail('Estado de propriedade invalido.');
41
+ const files = { ...state.files };
42
+ const derivedFile = checkedPath(root, '.pbai4devs/estado/derived.json');
43
+ if (checkedStat(root, derivedFile)) {
44
+ const derived = readState(derivedFile);
45
+ if (derived?.schemaVersion !== 1 || !validMap(derived.entries) || Object.keys(derived.entries).some(path => !['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'].includes(path))
46
+ || !validMap(derived.outputs ?? {}) || Object.keys(derived.outputs ?? {}).some(path => !/^\.[a-z0-9][a-z0-9-]*\/(?:skills|rules|agents)\.json$/.test(path))) fail('Estado de entradas derivadas invalido.');
47
+ Object.assign(files, derived.entries, derived.outputs);
48
+ }
49
+ for (const path of [...Object.keys(files), ...Object.keys(state.blocks)]) {
50
+ const location = checkedPath(root, path);
51
+ if (!checkedStat(root, location)?.isFile()) fail(`Arquivo gerenciado ausente: ${path}`);
52
+ }
53
+ return { files, blocks: state.blocks };
54
+ }
55
+
23
56
  export function registryRoots(root) {
24
57
  checkedStat(root, join(root, '.pbai4devs'));
25
58
  const file = join(root, '.pbai4devs/espelhos.conf');
@@ -44,7 +77,7 @@ function walk(root, location, files, { skipTools = true } = {}) {
44
77
  } else if (item.isFile() && ['.md', '.mdc'].includes(extname(location).toLowerCase())) files.add(location);
45
78
  }
46
79
 
47
- export function collectMarkdown(root, registry = registryRoots(root)) {
80
+ export function collectMarkdown(root, registry = registryRoots(root), { installed = ownership(root) } = {}) {
48
81
  const agents = join(root, '.pbai4devs');
49
82
  checkedStat(root, agents);
50
83
  for (const directory of readdirSync(agents, { withFileTypes: true })) {
@@ -54,10 +87,18 @@ export function collectMarkdown(root, registry = registryRoots(root)) {
54
87
  if (item?.isDirectory() && !checkedStat(root, join(location, 'README.md'))?.isFile()) fail(`README ausente: .pbai4devs/${directory.name}/`);
55
88
  }
56
89
  const files = new Set();
57
- const rootMarkdown = readdirSync(root).filter(name => ['.md', '.mdc'].includes(extname(name).toLowerCase()));
58
- const sources = ['.pbai4devs', 'docs', 'src', 'bin', 'tests', 'scripts', 'examples',
59
- '.github/PULL_REQUEST_TEMPLATE.md', '.github/ISSUE_TEMPLATE', ...rootMarkdown];
60
- for (const name of [...sources, ...registry.paths]) walk(root, resolve(root, name), files);
90
+ if (installed) {
91
+ walk(root, agents, files);
92
+ for (const path of Object.keys(installed.files)) {
93
+ const file = checkedPath(root, path);
94
+ if (['.md', '.mdc'].includes(extname(path).toLowerCase())) files.add(file);
95
+ }
96
+ } else {
97
+ const rootMarkdown = readdirSync(root).filter(name => ['.md', '.mdc'].includes(extname(name).toLowerCase()));
98
+ const sources = ['.pbai4devs', 'docs', 'src', 'bin', 'tests', 'scripts', 'examples',
99
+ '.github/PULL_REQUEST_TEMPLATE.md', '.github/ISSUE_TEMPLATE', ...rootMarkdown];
100
+ for (const name of [...sources, ...registry.paths]) walk(root, resolve(root, name), files);
101
+ }
61
102
  const tools = join(agents, 'tools');
62
103
  if (checkedStat(root, tools)) {
63
104
  const guide = join(tools, 'README.md');
@@ -154,8 +195,14 @@ export function checkCatalog(root, build = buildCatalog) {
154
195
  return result.catalog;
155
196
  }
156
197
 
157
- export function checkAgentExtras(root, agents) {
198
+ export function checkAgentExtras(root, agents, { installed = null } = {}) {
158
199
  for (const agent of agents) {
200
+ if (installed) {
201
+ const files = Object.keys(installed.files).filter(path => path.startsWith(`${agent}/`));
202
+ if (files.some(path => auxiliary.some(name => path === `${agent}/${name}` || path.startsWith(`${agent}/${name}/`)))) fail(`Auxiliar gerenciado indevido em ${agent}`);
203
+ if (files.some(path => ['README.md', 'INSTRUCTIONS.md'].includes(basename(path)))) fail(`Guia gerenciado indevido em ${agent}`);
204
+ continue;
205
+ }
159
206
  for (const name of auxiliary) if (existsSync(join(root, agent, name))) fail(`Auxiliar indevido em ${agent}/${name}`);
160
207
  const files = new Set();
161
208
  walk(root, join(root, agent), files);
@@ -183,14 +230,15 @@ export function checkLint(root, files, run = spawnSync) {
183
230
 
184
231
  export function verifyResources({ root = projectRoot, sourceOnly = false, synchronize = checkSynchronization, lint = checkLint, catalogCheck = checkCatalog } = {}) {
185
232
  requireNode();
233
+ const installed = sourceOnly ? null : ownership(root);
186
234
  const built = sourceOnly ? buildCatalog(root) : null;
187
235
  if (!sourceOnly) { synchronize(root); catalogCheck(root); }
188
236
  const registry = sourceOnly ? { agents: [], paths: [] } : registryRoots(root);
189
- const files = collectMarkdown(root, registry);
237
+ const files = collectMarkdown(root, registry, { installed });
190
238
  const virtualPaths = new Set(sourceOnly ? [...built.outputs.keys(), '.pbai4devs/espelhos.conf'] : []);
191
239
  checkMarkdown(root, files, { virtualPaths });
192
240
  checkResourceReferences(root, files);
193
- checkAgentExtras(root, registry.agents);
241
+ checkAgentExtras(root, registry.agents, { installed });
194
242
  return { files, messages: [...lint(root, files), 'recursos=passed'] };
195
243
  }
196
244