@pathbit/agentdevkit 0.0.20 → 0.0.22

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
- import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { isMain, projectRoot, requireNode } from '../lib/runtime.mjs';
@@ -9,6 +9,7 @@ import { isGeneratedOutput } from '../lib/gitignore.mjs';
9
9
  import { resourcePath, synchronize } from '../sincronizar-agentes/SCRIPT.mjs';
10
10
  import { adapterManifest } from '../lib/adapters.mjs';
11
11
  import { stateDirectory } from '../lib/state-path.mjs';
12
+ import { nativePathBoundary } from '../lib/native-paths.mjs';
12
13
 
13
14
  const assets = join(dirname(fileURLToPath(import.meta.url)), 'assets');
14
15
  const owner = 'testar-maturidade';
@@ -36,6 +37,39 @@ function noLinks(root, target) {
36
37
  }
37
38
  }
38
39
 
40
+ // The child binds its cwd to the verified parent directory. A swapped parent
41
+ // changes the inode or real path before the child can create the new directory.
42
+ const createDirectoryScript = `const fs=require('node:fs');
43
+ const [name,expected,dev,ino]=process.argv.slice(1);
44
+ const info=fs.statSync('.');
45
+ if(fs.realpathSync('.')!==expected || String(info.dev)!==dev || String(info.ino)!==ino) process.exit(73);
46
+ try { fs.mkdirSync(name); } catch(error) { if(error.code==='EEXIST') process.exit(17); throw error; }
47
+ `;
48
+ function createChild(root, parent, name, { allowExisting = false } = {}) {
49
+ if (!name || name === '.' || name === '..' || basename(name) !== name) fail('Nome de diretório inválido.');
50
+ noLinks(root, parent);
51
+ const info = stat(parent);
52
+ if (!info?.isDirectory() || info.isSymbolicLink()) fail('Diretório ancestral inválido.');
53
+ const canonical = realpathSync(parent), inside = relative(root, canonical);
54
+ if (isAbsolute(inside) || inside === '..' || inside.startsWith(`..${sep}`)) fail('Diretório ancestral fora do projeto.');
55
+ const env = { ...process.env }; delete env.NODE_TEST_CONTEXT;
56
+ const result = spawnSync(process.execPath, ['-e', createDirectoryScript, name, canonical, String(info.dev), String(info.ino)], { cwd: canonical, env, encoding: 'utf8', timeout: 30000, windowsHide: true, shell: false });
57
+ if (result.error || result.status !== 0 && !(allowExisting && result.status === 17)) fail(`Não foi possível criar diretório exclusivo: ${result.error?.message || result.stderr?.trim() || result.status}`);
58
+ noLinks(root, join(parent, name));
59
+ const output = stat(join(parent, name));
60
+ if (!output?.isDirectory() || output.isSymbolicLink()) fail('Diretório de saída inválido.');
61
+ }
62
+ function ensureDirectories(root, target) {
63
+ let current = root;
64
+ for (const part of relative(root, target).split(sep).filter(Boolean)) {
65
+ const next = join(current, part), info = stat(next);
66
+ if (!info) createChild(root, current, part, { allowExisting: true });
67
+ else if (!info.isDirectory() || info.isSymbolicLink()) fail('Diretório ancestral inválido ou link simbólico.');
68
+ noLinks(root, next);
69
+ current = next;
70
+ }
71
+ }
72
+
39
73
  export const SCENARIOS = [
40
74
  { id: 'somente-agentes', title: 'Somente agentes', expected: 'preparacao', complete: false, app: false, planning: 'none', note: 'Recursos compartilhados, sem produto ou aplicação.' },
41
75
  { id: 'descoberta', title: 'Descoberta de produto', expected: 'descoberta', complete: false, app: false, planning: 'draft', note: 'PRD em rascunho; nenhuma stack é iniciada.' },
@@ -63,11 +97,28 @@ export function registeredRoots(root) {
63
97
 
64
98
  export function resourceTargets(root, source, kind = 'skills') {
65
99
  const rows = manifest(root).split(/\r?\n/).filter(line => line && !line.startsWith('#')).map(line => line.split('|'));
66
- const targets = [];
100
+ const targets = [], suppressed = new Set();
101
+ for (const [type, fallback, primary, resource] of rows) {
102
+ if (type !== 'fallback' || resource !== kind) continue;
103
+ const primaryRow = rows.find(([entry, target]) => entry === 'agente' && target === primary);
104
+ const fallbackRow = rows.find(([entry, target]) => entry === 'agente' && target === fallback);
105
+ if (!primaryRow || !fallbackRow) fail(`Fallback sem cliente: ${fallback}`);
106
+ const primaryFormat = rows.find(([entry, target, scope]) => entry === 'formato' && target === primary && scope === kind)?.[3] || primaryRow[2] || 'agrupado';
107
+ const primaryTarget = `${primary}/${resourcePath(source, kind, primaryFormat)}`;
108
+ const boundary = nativePathBoundary(root, primaryTarget, [], { preserveRootAliases: true });
109
+ const info = boundary ? null : stat(join(root, primaryTarget));
110
+ if (boundary || (info && !info.isFile())) {
111
+ suppressed.add(primaryTarget);
112
+ targets.push(`${fallback}/${resourcePath(source, kind, fallbackRow[2] || 'agrupado')}`);
113
+ }
114
+ }
67
115
  for (const [type, agent, layout = 'agrupado'] of rows) {
68
116
  if (type !== 'agente') continue;
69
117
  const override = rows.find(([entry, target, resource]) => entry === 'formato' && target === agent && resource === kind)?.[3];
70
- targets.push(`${agent}/${resourcePath(source, kind, override || layout)}`);
118
+ if (override === 'nenhum') continue;
119
+ let target = `${agent}/${resourcePath(source, kind, override || layout)}`;
120
+ if (agent === '.gemini' && kind === 'commands') target = target.replace(/\.md$/, '.toml');
121
+ if (!suppressed.has(target)) targets.push(target);
71
122
  }
72
123
  for (const [type, target, origin] of rows) if (type === 'nativo' && origin === source) targets.push(target);
73
124
  return [...new Set(targets)];
@@ -139,29 +190,6 @@ export function copyAgentStructure(sourceRoot, destination) {
139
190
  return { roots, copiedFiles: files.length, skipped, files, sourceDigest, entrypoints, toolDefinitions: files.filter(file => /^\.pbai4devs\/tools\/[^/]+\/TOOL\.json$/.test(file)).length };
140
191
  }
141
192
 
142
- function ownership(root, file, expectedId) {
143
- if (stat(root)?.isSymbolicLink()) return false;
144
- const path = join(root, file);
145
- if (!existsSync(path)) return false;
146
- const item = lstatSync(path);
147
- if (!item.isFile() || item.isSymbolicLink()) return false;
148
- try { const data = json(path); return data.owner === owner && (!expectedId || data.id === expectedId); }
149
- catch { return false; }
150
- }
151
-
152
- function ensureOutput(output) {
153
- if (existsSync(output) && lstatSync(output).isSymbolicLink()) fail('A pasta de exemplos não pode ser um link.');
154
- for (const name of [suiteMarker, 'README.md', 'REPORT.json', '.gitignore']) {
155
- const item = stat(join(output, name));
156
- if (item && (!item.isFile() || item.isSymbolicLink())) fail(`Arquivo de saída deve ser regular: ${name}`);
157
- }
158
- if (existsSync(output) && [suiteMarker, 'README.md', 'REPORT.json'].some(name => existsSync(join(output, name)))
159
- && !ownership(output, suiteMarker)) fail('Índice de exemplos existente não pertence a este gerador.');
160
- if (existsSync(join(output, 'REPORT.json')) && !ownership(output, 'REPORT.json')) fail('Relatório de exemplos existente não pertence a este gerador.');
161
- if (existsSync(join(output, '.gitignore')) && readFileSync(join(output, '.gitignore'), 'utf8') !== outputIgnore) fail('Ignore de exemplos existente não pertence a este gerador.');
162
- mkdirSync(output, { recursive: true });
163
- }
164
-
165
193
  function planning(root, scenario) {
166
194
  const stateRoot = stateDirectory(root);
167
195
  const state = `${stateRoot}/configuracao/ESTADO.md`;
@@ -228,6 +256,13 @@ function application(root, scenario) {
228
256
  preserved.push(server, test, 'config/legacy.json', '.github/workflows/legacy.yml', '.vscode/settings.json', 'Makefile', 'package.json');
229
257
  }
230
258
  if (scenario.configured) {
259
+ // Published packages and existing projects need not contain these files;
260
+ // create only the missing synthetic fixtures, never replace copied content.
261
+ for (const [file, content] of Object.entries({
262
+ '.gitignore': '# Arquivos temporários do cenário sintético.\nnode_modules/\n.agent-tests/\n',
263
+ '.gitattributes': '* text=auto\n',
264
+ '.editorconfig': 'root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\n',
265
+ })) if (!stat(join(root, file))) write(root, file, content);
231
266
  write(root, '.dockerignore', '.git\n.agent-tests\nnode_modules\n');
232
267
  write(root, '.env.example', 'PORT=3000\n');
233
268
  write(root, 'Dockerfile', 'FROM node:24-alpine\nWORKDIR /app\nCOPY package.json ./\nCOPY src ./src\nEXPOSE 3000\nCMD ["node", "src/server.mjs"]\n');
@@ -242,31 +277,37 @@ function gitInit(root) {
242
277
  if (result.error || result.status !== 0) fail(`Git init: ${result.error?.message || result.stderr}`);
243
278
  }
244
279
 
245
- export function prepareExamples({ sourceRoot = projectRoot, outputRoot = defaultOutput(sourceRoot), scenarios = SCENARIOS, initializeGit = true, renderResources = prepareResources } = {}) {
246
- requireNode(); sourceRoot = realpathSync(resolve(sourceRoot)); outputRoot = resolve(outputRoot);
247
- const outputPath = portable(relative(sourceRoot, outputRoot));
280
+ export function prepareExamples({ sourceRoot = projectRoot, outputRoot, scenarios = SCENARIOS, initializeGit = true, renderResources = prepareResources } = {}) {
281
+ requireNode(); sourceRoot = realpathSync(resolve(sourceRoot));
282
+ const base = resolve(outputRoot ?? defaultOutput(sourceRoot));
283
+ const outputPath = portable(relative(sourceRoot, base));
248
284
  if (!(outputPath === 'examples/generated' || outputPath.startsWith('examples/generated/')
249
285
  || outputPath === 'tmp/examples' || outputPath.startsWith('tmp/examples/') || outputPath.startsWith('.agent-tests/'))) fail('Saída permitida somente em examples/generated, tmp/examples ou descendente de .agent-tests.');
250
- noLinks(sourceRoot, outputRoot);
286
+ noLinks(sourceRoot, base);
251
287
  const ids = new Set();
288
+ if (!Array.isArray(scenarios) || !scenarios.length) fail('Selecione pelo menos um cenário.');
252
289
  for (const scenario of scenarios) {
253
290
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(scenario.id) || ids.has(scenario.id)) fail('Cenários devem ter IDs únicos e seguros.');
254
291
  ids.add(scenario.id);
255
292
  }
256
- ensureOutput(outputRoot);
257
- for (const scenario of scenarios) {
258
- const destination = join(outputRoot, scenario.id);
259
- noLinks(sourceRoot, destination);
260
- if (existsSync(destination) && !ownership(destination, marker, scenario.id)) fail(`Diretório existente não é gerenciado: ${destination}`);
293
+ if (outputRoot !== undefined && stat(base)) fail(`Pasta de saída já existe; escolha um destino novo: ${base}`);
294
+ if (outputRoot !== undefined) {
295
+ for (let parent = dirname(base); parent !== sourceRoot && parent.startsWith(sourceRoot + sep); parent = dirname(parent)) {
296
+ if (stat(join(parent, suiteMarker)) || stat(join(parent, marker))) fail('Saída não pode ficar dentro de uma execução anterior.');
297
+ }
261
298
  }
299
+ const parent = outputRoot === undefined ? base : dirname(base);
300
+ ensureDirectories(sourceRoot, parent);
301
+ const name = outputRoot === undefined ? `maturity-${randomUUID()}` : basename(base);
302
+ createChild(sourceRoot, parent, name);
303
+ outputRoot = join(parent, name);
262
304
  write(outputRoot, suiteMarker, JSON.stringify({ owner, version: 1 }, null, 2) + '\n');
263
305
  write(outputRoot, '.gitignore', outputIgnore);
264
- rmSync(join(outputRoot, 'REPORT.json'), { force: true });
265
306
  writeIndex(outputRoot, [], 'preparing');
266
307
  const examples = [];
267
308
  for (const scenario of scenarios) {
268
309
  const destination = join(outputRoot, scenario.id);
269
- rmSync(destination, { recursive: true, force: true }); mkdirSync(destination, { recursive: true });
310
+ mkdirSync(destination);
270
311
  write(destination, marker, JSON.stringify({ owner, version: 1, id: scenario.id }, null, 2) + '\n');
271
312
  const copied = copyAgentStructure(sourceRoot, destination);
272
313
  planning(destination, scenario);
@@ -283,8 +324,8 @@ export function prepareExamples({ sourceRoot = projectRoot, outputRoot = default
283
324
 
284
325
  function writeIndex(outputRoot, examples, status) {
285
326
  const rows = examples.map(item => `| [${item.id}](${item.id}/README.md) | ${item.expected} | ${item.status || 'prepared'} |`).join('\n');
286
- const progress = status === 'preparing' ? '\nPreparação em andamento; os resultados anteriores foram invalidados.\n' : '';
287
- write(outputRoot, 'README.md', document('Exemplos de maturidade', `Projetos sintéticos com cópias locais da estrutura de agentes.\nNenhum resultado certifica produção, revisão humana ou outros sistemas operacionais.\n${progress}\n| Cenário | Nível esperado | Resultado |\n| --- | --- | --- |${rows ? '\n' + rows : ''}\n\nRelatório: \`REPORT.json\`. Logs e evidências por projeto ficam em \`.agent-tests/\`.\nPastas sem marcador deste gerador são preservadas.\n`, `status: ${status}\n`));
327
+ const progress = status === 'preparing' ? '\nPreparação em andamento nesta execução.\n' : '';
328
+ write(outputRoot, 'README.md', document('Exemplos de maturidade', `Projetos sintéticos com cópias locais da estrutura de agentes.\nNenhum resultado certifica produção, revisão humana ou outros sistemas operacionais.\n${progress}\n| Cenário | Nível esperado | Resultado |\n| --- | --- | --- |${rows ? '\n' + rows : ''}\n\nRelatório: \`REPORT.json\`. Logs e evidências por projeto ficam em \`.agent-tests/\`.\nCada execução usa uma pasta nova e preserva as anteriores.\n`, `status: ${status}\n`));
288
329
  }
289
330
 
290
331
  function hashes(root, files) { return Object.fromEntries(files.map(file => [file, digest(readFileSync(join(root, file)))])); }
@@ -453,7 +494,7 @@ export function testExample(example) {
453
494
  export function runExamples(options = {}) {
454
495
  const examples = prepareExamples(options);
455
496
  const results = examples.map(example => { const result = testExample(example); console.log(`${example.id}=${result.status}${result.error ? ': ' + result.error : ''}`); return result; });
456
- const outputRoot = resolve(options.outputRoot || defaultOutput(options.sourceRoot || projectRoot));
497
+ const outputRoot = dirname(examples[0].root);
457
498
  const report = { owner, version: 1, status: results.every(item => item.status === 'passed') ? 'passed' : 'failed', platform: process.platform, arch: process.arch, node: process.version, results, limits: ['Provas reais somente das operações descritas e APIs locais.', 'Pareceres do gate são simulações explícitas, sem revisão humana.', 'Não certifica produção nem sistemas operacionais não executados.'] };
458
499
  write(outputRoot, 'REPORT.json', JSON.stringify(report, null, 2) + '\n');
459
500
  writeIndex(outputRoot, examples.map(example => ({ ...example, status: results.find(result => result.id === example.id).status })), report.status);
@@ -462,15 +503,16 @@ export function runExamples(options = {}) {
462
503
 
463
504
  export function main(args = process.argv.slice(2)) {
464
505
  requireNode(); const [mode = 'help', ...rest] = args;
465
- if (['help', '--help', '-h'].includes(mode)) { console.log('Uso: testar-maturidade prepare|run [--case <id>]. Exemplos gerados em examples/generated; apenas pastas marcadas são substituídas.'); return 0; }
506
+ if (['help', '--help', '-h'].includes(mode)) { console.log('Uso: testar-maturidade prepare|run [--case <id>]. Cada execução cria uma nova pasta em examples/generated.'); return 0; }
466
507
  if (!['prepare', 'run'].includes(mode)) fail('Use prepare ou run.');
467
508
  let scenarios = SCENARIOS;
468
509
  if (rest.length) {
469
510
  if (rest.length !== 2 || rest[0] !== '--case') fail('Use --case <id>.');
470
511
  scenarios = SCENARIOS.filter(item => item.id === rest[1]); if (!scenarios.length) fail('Cenário desconhecido.');
471
512
  }
472
- if (mode === 'prepare') { const examples = prepareExamples({ scenarios }); console.log(`prepared=${examples.length}`); return 0; }
473
- return runExamples({ scenarios }).report.status === 'passed' ? 0 : 1;
513
+ if (mode === 'prepare') { const examples = prepareExamples({ scenarios }); console.log(`prepared=${examples.length}\noutput=${dirname(examples[0].root)}`); return 0; }
514
+ const result = runExamples({ scenarios }); console.log(`output=${result.outputRoot}`);
515
+ return result.report.status === 'passed' ? 0 : 1;
474
516
  }
475
517
 
476
518
  if (isMain(import.meta.url)) {
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
- import { spawnSync } from 'node:child_process';
4
+ import childProcess, { spawnSync } from 'node:child_process';
5
+ import { syncBuiltinESMExports } from 'node:module';
5
6
  import { createHash } from 'node:crypto';
6
7
  import test from 'node:test';
7
8
  import { projectRoot, requireNode } from '../../lib/runtime.mjs';
@@ -55,21 +56,61 @@ test('copia recursos e configurações, excluindo payloads, caches e segredos',
55
56
  assert.deepEqual(result.roots, ['.pbai4devs', '.codex', '.github', '.agents', '.agent', '.gemini', '.vscode']);
56
57
  });
57
58
 
58
- test('regeneração preserva pastas alheias e recusa sobrescrevê-las', t => {
59
+ test('destino explícito existente é recusado sem alterar projetos humanos ou gerados', t => {
59
60
  const { root, put, outputRoot } = fixture(t);
60
61
  put('examples/generated/anotacoes/keep.txt', 'preservado');
61
62
  put('examples/README.md', 'Guia humano.');
62
63
  const options = { sourceRoot: root, outputRoot, scenarios: [SCENARIOS[0]], initializeGit: false };
63
- prepareExamples(options); prepareExamples(options);
64
+ assert.throws(() => prepareExamples(options), /saída já existe/);
64
65
  assert.equal(readFileSync(join(outputRoot, 'anotacoes/keep.txt'), 'utf8'), 'preservado');
65
66
  assert.equal(readFileSync(join(root, 'examples/README.md'), 'utf8'), 'Guia humano.');
66
- put('examples/generated/descoberta/keep.txt', 'humano');
67
- assert.throws(() => prepareExamples({ ...options, scenarios: [SCENARIOS[1]] }), /não é gerenciado/);
68
- assert.equal(readFileSync(join(outputRoot, 'descoberta/keep.txt'), 'utf8'), 'humano');
69
- assert.throws(() => prepareExamples({ ...options, scenarios: [{ ...SCENARIOS[0], id: '../outside' }] }), /IDs únicos e seguros/);
67
+ const output = join(outputRoot, 'nova-execucao');
68
+ const [example] = prepareExamples({ ...options, outputRoot: output });
69
+ writeFileSync(join(example.root, 'anotacao-humana.txt'), 'não apagar');
70
+ const report = readFileSync(join(output, 'REPORT.json'));
71
+ assert.throws(() => prepareExamples({ ...options, outputRoot: output, scenarios: [SCENARIOS[1]] }), /saída já existe/);
72
+ assert.throws(() => prepareExamples({ ...options, outputRoot: join(example.root, 'nova'), scenarios: [SCENARIOS[1]] }), /execução anterior/);
73
+ assert.deepEqual(readFileSync(join(output, 'REPORT.json')), report);
74
+ assert.equal(readFileSync(join(example.root, 'anotacao-humana.txt'), 'utf8'), 'não apagar');
75
+ assert.throws(() => prepareExamples({ ...options, outputRoot: join(outputRoot, 'invalid'), scenarios: [{ ...SCENARIOS[0], id: '../outside' }] }), /IDs únicos e seguros/);
76
+ assert.equal(existsSync(join(outputRoot, 'invalid')), false);
70
77
  });
71
78
 
72
- test('destino padrão redefine apenas relatório próprio e mantém resultados fora do Git', t => {
79
+ test('troca de ancestral por symlink antes da criação não escreve no destino externo', t => {
80
+ const { root, outputRoot } = fixture(t);
81
+ const outside = mkdtempSync(join(dirname(root), 'maturity-outside-'));
82
+ t.after(() => rmSync(outside, { recursive: true, force: true }));
83
+ mkdirSync(outputRoot, { recursive: true });
84
+ const externalSentinel = join(outside, 'sentinel.txt');
85
+ writeFileSync(externalSentinel, 'externo preservado\n');
86
+ const linkProbe = join(root, 'probe-link');
87
+ try { symlinkSync(outside, linkProbe, process.platform === 'win32' ? 'junction' : 'dir'); }
88
+ catch (error) { if (['EPERM', 'EACCES'].includes(error.code)) { t.skip('Sem permissão para links simbólicos.'); return; } throw error; }
89
+ unlinkSync(linkProbe);
90
+ const canonical = realpathSync(outputRoot);
91
+ const original = childProcess.spawnSync;
92
+ let injected = false;
93
+ childProcess.spawnSync = function (file, args, options) {
94
+ if (!injected && file === process.execPath && args?.[0] === '-e' && options?.cwd === canonical) {
95
+ injected = true;
96
+ renameSync(outputRoot, `${outputRoot}-original`);
97
+ symlinkSync(outside, outputRoot, process.platform === 'win32' ? 'junction' : 'dir');
98
+ }
99
+ return original.call(this, file, args, options);
100
+ };
101
+ syncBuiltinESMExports();
102
+ try {
103
+ assert.throws(() => prepareExamples({ sourceRoot: root, outputRoot: join(outputRoot, 'nova'), scenarios: [SCENARIOS[0]], initializeGit: false }), /diretório exclusivo|ancestral|link simbólico/i);
104
+ assert.equal(injected, true);
105
+ assert.deepEqual(readdirSync(outside), ['sentinel.txt']);
106
+ assert.equal(readFileSync(externalSentinel, 'utf8'), 'externo preservado\n');
107
+ } finally {
108
+ childProcess.spawnSync = original;
109
+ syncBuiltinESMExports();
110
+ }
111
+ });
112
+
113
+ test('cada destino padrão é exclusivo, preserva provas anteriores e permanece ignorado', t => {
73
114
  const { root, outputRoot } = fixture(t);
74
115
  const git = (...args) => {
75
116
  const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', shell: false });
@@ -77,48 +118,58 @@ test('destino padrão redefine apenas relatório próprio e mantém resultados f
77
118
  };
78
119
  git('init', '--template=', '--quiet');
79
120
  const options = { sourceRoot: root, scenarios: [SCENARIOS[0]], initializeGit: false };
80
- const examples = prepareExamples(options);
81
- assert.equal(examples[0].root, join(outputRoot, SCENARIOS[0].id));
82
- const reportFile = join(outputRoot, 'REPORT.json');
83
- writeFileSync(reportFile, JSON.stringify({ owner: 'testar-maturidade', status: 'passed', results: [{ stale: true }] }));
84
- prepareExamples(options);
85
- assert.deepEqual(JSON.parse(readFileSync(reportFile)), { owner: 'testar-maturidade', version: 1,
86
- status: 'prepared', scenarios: [SCENARIOS[0].id], results: [] });
87
- assert.match(git('check-ignore', '--verbose', 'examples/generated/REPORT.json'), /examples\/generated\/\.gitignore:2:\*/);
88
- assert.match(git('check-ignore', '--verbose', 'examples/generated/somente-agentes/README.md'), /examples\/generated\/\.gitignore:2:\*/);
89
- const human = JSON.stringify({ owner: 'human', notes: 'preservar' });
90
- writeFileSync(reportFile, human);
91
- assert.throws(() => prepareExamples(options), /Relatório.*não pertence/);
92
- assert.equal(readFileSync(reportFile, 'utf8'), human);
121
+ const [first] = prepareExamples(options);
122
+ const firstOutput = dirname(first.root);
123
+ assert.equal(dirname(firstOutput), outputRoot);
124
+ const human = 'Prova humana que não pode ser apagada.\n';
125
+ writeFileSync(join(first.root, 'human.txt'), human);
126
+ const reportFile = join(firstOutput, 'REPORT.json');
127
+ const oldReport = readFileSync(reportFile);
128
+ const [second] = prepareExamples(options);
129
+ assert.notEqual(dirname(second.root), firstOutput);
130
+ assert.equal(readFileSync(join(first.root, 'human.txt'), 'utf8'), human);
131
+ assert.deepEqual(readFileSync(reportFile), oldReport);
132
+ for (const file of [join(firstOutput, 'REPORT.json'), join(first.root, 'README.md'), join(second.root, 'README.md')]) {
133
+ git('check-ignore', '--quiet', '--', file);
134
+ }
93
135
  });
94
136
 
95
- test('preserva ignore humano e continua aceitando destinos legados', t => {
137
+ test('preserva ignore humano em pastas ancestrais e aceita destinos novos permitidos', t => {
96
138
  const { root, put } = fixture(t);
97
139
  const options = { sourceRoot: root, scenarios: [SCENARIOS[0]], initializeGit: false };
98
140
  put('examples/generated/.gitignore', '!README.md\n');
99
- assert.throws(() => prepareExamples(options), /Ignore.*não pertence/);
141
+ put('examples/generated/.maturity-suite.json', '{"owner":"testar-maturidade"}\n');
142
+ put('examples/generated/somente-agentes/.maturity-case.json', '{"owner":"testar-maturidade","id":"somente-agentes"}\n');
143
+ put('examples/generated/somente-agentes/anotacao.txt', 'Conteúdo humano anterior.\n');
144
+ put('examples/generated/REPORT.json', '{"resultado":"anterior"}\n');
145
+ const [generated] = prepareExamples(options);
146
+ assert.equal(dirname(dirname(generated.root)), join(root, 'examples/generated'));
100
147
  assert.equal(readFileSync(join(root, 'examples/generated/.gitignore'), 'utf8'), '!README.md\n');
101
- for (const target of ['tmp/examples', '.agent-tests/cases']) {
148
+ assert.equal(readFileSync(join(root, 'examples/generated/somente-agentes/anotacao.txt'), 'utf8'), 'Conteúdo humano anterior.\n');
149
+ assert.equal(readFileSync(join(root, 'examples/generated/REPORT.json'), 'utf8'), '{"resultado":"anterior"}\n');
150
+ for (const target of ['tmp/examples/caso-novo', '.agent-tests/cases']) {
102
151
  const outputRoot = join(root, target);
103
152
  const [example] = prepareExamples({ ...options, outputRoot });
104
153
  assert.equal(example.root, join(outputRoot, SCENARIOS[0].id));
105
154
  }
106
155
  });
107
156
 
108
- test('preparação interrompida não conserva resultado anterior nem declara sucesso', t => {
157
+ test('preparação interrompida não altera resultado anterior nem declara sucesso', t => {
109
158
  const { root, put, outputRoot } = fixture(t);
110
159
  const options = { sourceRoot: root, scenarios: [SCENARIOS[0]], initializeGit: false };
111
- prepareExamples(options);
112
- const report = join(outputRoot, 'REPORT.json');
113
- const index = join(outputRoot, 'README.md');
114
- writeFileSync(report, JSON.stringify({ owner: 'testar-maturidade', status: 'passed', results: [{}] }));
115
- writeFileSync(index, readFileSync(index, 'utf8').replaceAll('prepared', 'passed'));
160
+ const [first] = prepareExamples(options);
161
+ const firstOutput = dirname(first.root);
162
+ const report = join(firstOutput, 'REPORT.json');
163
+ const previous = readFileSync(report);
116
164
  put('.pbai4devs/state/agentdevkit.json', JSON.stringify({ blocks: null }));
117
165
  assert.throws(() => prepareExamples(options), /Estado da instalação/);
118
- assert.equal(existsSync(report), false);
166
+ assert.deepEqual(readFileSync(report), previous);
167
+ const failed = readdirSync(outputRoot).filter(name => join(outputRoot, name) !== firstOutput);
168
+ assert.equal(failed.length, 1);
169
+ const index = join(outputRoot, failed[0], 'README.md');
119
170
  assert.match(readFileSync(index, 'utf8'), /status: preparing/);
171
+ assert.equal(existsSync(join(outputRoot, failed[0], 'REPORT.json')), false);
120
172
  assert.doesNotMatch(readFileSync(index, 'utf8'), /status: passed|\| passed \|/);
121
- assert.doesNotMatch(readFileSync(index, 'utf8'), /\n{3,}/);
122
173
  });
123
174
 
124
175
  test('usa entradas distribuídas do hub e mantém fallback legado', t => {
@@ -216,6 +267,38 @@ test('localiza apontamentos pelo formato de cada tipo e por overrides nativos',
216
267
  ]);
217
268
  });
218
269
 
270
+ test('respeita tipo sem espelho e o formato TOML nativo dos comandos Gemini', t => {
271
+ const { root, put } = fixture(t);
272
+ put('.pbai4devs/espelhos.conf', [
273
+ 'agente|.agents|agrupado',
274
+ 'agente|.codex|agrupado', 'formato|.codex|skills|nenhum',
275
+ 'agente|.gemini|agrupado', 'formato|.gemini|commands|arquivo',
276
+ ].join('\n') + '\n');
277
+ const skill = resourceTargets(root, '.pbai4devs/skills/projeto/demo/SKILL.md');
278
+ assert.deepEqual(skill, ['.agents/skills/projeto/demo/SKILL.md', '.gemini/skills/projeto/demo/SKILL.md']);
279
+ assert.ok(!skill.some(target => target.startsWith('.codex/')));
280
+ const command = resourceTargets(root, '.pbai4devs/commands/projeto/demo/COMMAND.md', 'commands');
281
+ assert.ok(command.includes('.codex/commands/projeto/demo/COMMAND.md'));
282
+ assert.ok(command.includes('.gemini/commands/projeto/demo.toml'));
283
+ assert.ok(!command.includes('.gemini/commands/projeto/demo.md'));
284
+ });
285
+
286
+ test('identifica o fallback Codex apenas para a skill cuja rota primária está obstruída', t => {
287
+ const { root, put } = fixture(t);
288
+ put('.pbai4devs/espelhos.conf', [
289
+ 'agente|.agents|agrupado', 'agente|.codex|agrupado',
290
+ 'formato|.codex|skills|nenhum', 'fallback|.codex|.agents|skills',
291
+ ].join('\n') + '\n');
292
+ const blocked = '.pbai4devs/skills/projeto/demo/SKILL.md';
293
+ const available = '.pbai4devs/skills/projeto/outra/SKILL.md';
294
+ const target = join(root, '.agents/skills/projeto/demo/SKILL.md');
295
+ mkdirSync(target, { recursive: true });
296
+ assert.deepEqual(resourceTargets(root, blocked), ['.codex/skills/projeto/demo/SKILL.md']);
297
+ assert.deepEqual(resourceTargets(root, available), ['.agents/skills/projeto/outra/SKILL.md']);
298
+ rmSync(target, { recursive: true });
299
+ assert.deepEqual(resourceTargets(root, blocked), ['.agents/skills/projeto/demo/SKILL.md']);
300
+ });
301
+
219
302
  test('checkout de fontes sem manifesto deriva clientes em memória', t => {
220
303
  const { root, put } = fixture(t);
221
304
  const manifest = join(root, '.pbai4devs/espelhos.conf');
@@ -277,6 +360,47 @@ test('oito cópias exercitam os seis níveis e preservam layouts legado/monorepo
277
360
  assert.equal(existsSync(join(monorepo.root, 'src')), false);
278
361
  });
279
362
 
363
+ test('cenários configurados completam fixtures ausentes sem alterar a origem ou arquivos copiados', t => {
364
+ const { root, put, outputRoot } = fixture(t);
365
+ const files = ['.gitignore', '.gitattributes', '.editorconfig'];
366
+ const source = Object.fromEntries(files.map(file => [file, `# configuração própria de ${file}\n`]));
367
+ // As duas fontes ausentes reproduzem uma instalação distribuída; o arquivo
368
+ // existente prova que a preparação não substitui configurações copiadas.
369
+ rmSync(join(root, '.gitattributes'));
370
+ rmSync(join(root, '.editorconfig'));
371
+ put('.gitignore', source['.gitignore']);
372
+ const scenarios = SCENARIOS.filter(example => ['app-configurada', 'manutencao', 'app-inicial'].includes(example.id));
373
+ const examples = prepareExamples({ sourceRoot: root, outputRoot: join(outputRoot, 'parcial'), scenarios, initializeGit: false });
374
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), source['.gitignore']);
375
+ for (const file of ['.gitattributes', '.editorconfig']) assert.equal(existsSync(join(root, file)), false, file);
376
+ for (const example of examples) {
377
+ assert.equal(readFileSync(join(example.root, '.gitignore'), 'utf8'), source['.gitignore']);
378
+ if (!example.configured) {
379
+ for (const file of ['.gitattributes', '.editorconfig']) assert.equal(existsSync(join(example.root, file)), false, file);
380
+ continue;
381
+ }
382
+ for (const file of ['.gitattributes', '.editorconfig']) assert.ok(readFileSync(join(example.root, file), 'utf8').trim(), file);
383
+ const inspected = spawnSync(process.execPath, ['.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs', '--check'], { cwd: example.root, encoding: 'utf8' });
384
+ assert.ifError(inspected.error);
385
+ assert.equal(inspected.status, 0, inspected.stderr || inspected.stdout);
386
+ assert.equal(JSON.parse(inspected.stdout).contract.complete, true, example.id);
387
+ }
388
+ // Retest with every file absent, as in the released npm tarball.
389
+ rmSync(join(root, '.gitignore'));
390
+ const configured = scenarios.find(example => example.id === 'app-configurada');
391
+ const missing = prepareExamples({ sourceRoot: root, outputRoot: join(outputRoot, 'ausentes'), scenarios: [configured], initializeGit: false })[0];
392
+ for (const file of files) {
393
+ assert.equal(existsSync(join(root, file)), false, file);
394
+ assert.ok(readFileSync(join(missing.root, file), 'utf8').trim(), file);
395
+ }
396
+ for (const file of files) put(file, source[file]);
397
+ const preserved = prepareExamples({ sourceRoot: root, outputRoot: join(outputRoot, 'presentes'), scenarios: [configured], initializeGit: false })[0];
398
+ for (const file of files) {
399
+ assert.equal(readFileSync(join(root, file), 'utf8'), source[file]);
400
+ assert.equal(readFileSync(join(preserved.root, file), 'utf8'), source[file]);
401
+ }
402
+ });
403
+
280
404
  test('fixtures padrão, legado e monorepo executam HTTP real e encerram', t => {
281
405
  const { root, outputRoot } = fixture(t);
282
406
  const scenarios = SCENARIOS.filter(example => ['app-inicial', 'legado', 'monorepo'].includes(example.id));
@@ -49,9 +49,20 @@ function ownership(root) {
49
49
  if (checkedStat(root, derivedFile)) {
50
50
  const derived = readState(derivedFile);
51
51
  if (derived?.schemaVersion !== 1 || !validMap(derived.entries) || Object.keys(derived.entries).some(path => !canonicalEntrypoint(path))
52
- || !validMap(derived.files ?? {}) || !validMap(derived.blocks ?? {})
52
+ || !validMap(derived.files ?? {}) || !validMap(derived.blocks ?? {}) || !validMap(derived.removedFiles ?? {})
53
53
  || !Array.isArray(derived.removedBlocks ?? []) || (derived.removedBlocks ?? []).some(path => typeof path !== 'string')
54
54
  || !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.');
55
+ for (const [path, hash] of Object.entries(derived.removedFiles ?? {})) {
56
+ const [nativeRoot, scope] = path.split('/');
57
+ if (!/^\.[A-Za-z0-9][A-Za-z0-9._-]*$/.test(nativeRoot) || ['.git', '.pbai4devs', '.agent-tests'].includes(nativeRoot.toLowerCase())
58
+ || !scope || [...auxiliary, 'workflows'].includes(scope.toLowerCase()) || ['readme.md', 'instructions.md'].includes(basename(path).toLowerCase())
59
+ || !(/\.(?:md|mdc)$/i.test(path) || path.startsWith('.gemini/commands/') && path.endsWith('.toml'))) fail('Registro de arquivos removidos invalido.');
60
+ const location = checkedPath(root, path);
61
+ if (state.files[path] && state.files[path] !== hash) fail(`Registro de arquivos removidos divergente: ${path}`);
62
+ if (state.files[path] !== hash || derived.files?.[path] || derived.blocks?.[path] || derived.entries?.[path]) continue;
63
+ if (checkedStat(root, location)) fail(`Arquivo removido foi recriado; revise o conteúdo: ${path}`);
64
+ delete files[path];
65
+ }
55
66
  Object.assign(files, derived.entries, derived.outputs, derived.files);
56
67
  for (const [path, hash] of Object.entries({ ...derived.entries, ...derived.files })) {
57
68
  const location = checkedPath(root, path);
@@ -196,6 +196,28 @@ test('instalação também protege manifestos registrados em derived.outputs', t
196
196
  assert.throws(() => verify(root), /Arquivo gerenciado ausente: .agents\/skills.json/);
197
197
  });
198
198
 
199
+ test('remoções derivadas exigem hash compatível e não ocultam arquivo humano recriado', t => {
200
+ const { root, file, generated } = installedFixture(t);
201
+ const statePath = join(root, '.pbai4devs/state/agentdevkit.json');
202
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
203
+ const hash = state.files[generated], derivedPath = '.pbai4devs/state/derived.json';
204
+ rmSync(join(root, generated));
205
+ const record = removedFiles => file(derivedPath, JSON.stringify({ schemaVersion: 1, entries: {}, removedFiles }));
206
+ record({ [generated]: hash });
207
+ assert.doesNotThrow(() => verify(root));
208
+ file(generated, document('# Conteúdo recriado pelo usuário\n'));
209
+ const recreated = readFileSync(join(root, generated));
210
+ assert.throws(() => verify(root), /Arquivo removido foi recriado/);
211
+ assert.deepEqual(readFileSync(join(root, generated)), recreated);
212
+ rmSync(join(root, generated));
213
+ record({ [generated]: 'f'.repeat(64) });
214
+ assert.throws(() => verify(root), /Registro de arquivos removidos divergente/);
215
+ record({ [generated]: 'invalido' });
216
+ assert.throws(() => verify(root), /Estado de entradas derivadas invalido/);
217
+ record({ '.pbai4devs/skills/exemplo/SKILL.md': hash });
218
+ assert.throws(() => verify(root), /Registro de arquivos removidos invalido/);
219
+ });
220
+
199
221
  test('catalogo derivado cobre recursos e detecta JSON editado sem reescrever', t => {
200
222
  const { root, file } = resourceFixture(t);
201
223
  file('.pbai4devs/commands/inspecionar/COMMAND.md', document('# Inspecionar\n\n[Script](../../scripts/inspecionar/SCRIPT.mjs).\n'));
@@ -79,9 +79,10 @@
79
79
  "name": "Codex",
80
80
  "root": ".codex",
81
81
  "formats": {
82
- "skills": "agrupado"
82
+ "skills": "nenhum"
83
83
  },
84
- "reason": "Skills agrupadas; a entrada nativa de projeto é .agents/skills. Outros recursos em .codex são referências, não registros de agentes.",
84
+ "skillFallback": ".agents",
85
+ "reason": "Skills usam apenas .agents/skills para evitar descoberta duplicada; os demais recursos em .codex são referências.",
85
86
  "sources": [
86
87
  "https://learn.chatgpt.com/docs/build-skills",
87
88
  "https://github.com/openai/codex/blob/392ced7d3a9160fd35d40c606359869215f50522/codex-rs/ext/skills/src/loader/host.rs"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.22 — 2026-11-13
8
+
9
+ - Descoberta de skills do Codex sem duplicação, com alternativa segura quando a
10
+ rota principal está obstruída.
11
+ - Sincronização e atualização reconciliam o estado sem substituir recursos ou
12
+ metadados humanos.
13
+ - Scripts reconhecem caminhos por aliases e exemplos de maturidade usam saídas isoladas.
14
+ - Testes ampliados para combinações de clientes, projetos existentes e
15
+ preservação do Git.
16
+
17
+ ## 0.0.21 — 2026-11-13
18
+
19
+ - Criação automática da tag da versão após aprovação da matriz de CI.
20
+ - Publicação acionada pela automação com proteção contra colisões de tags.
21
+
7
22
  ## 0.0.20 — 2026-11-13
8
23
 
9
24
  - Atualização da ferramenta agent-browser compatível com sua distribuição atual.
@@ -75,8 +75,15 @@ nada. Nunca versione o tarball ou seus relatórios.
75
75
 
76
76
  1. Atualize versão e lockfile, changelog, exemplos e escopo da entrega.
77
77
  2. Conclua testes, documentação e revisão independente.
78
- 3. Envie o commit à branch principal e aguarde o CI aprovado.
79
- 4. Crie e envie a tag `v<versão>` para esse mesmo commit.
78
+ 3. Envie o commit à branch principal e acompanhe o CI e a release.
79
+
80
+ Após toda a matriz de CI passar, a automação cria a tag `v<versão>` no
81
+ commit verificado e inicia o workflow de release. Um push sem mudança de versão
82
+ publicada não gera outra release. Uma tag existente não é movida: se a versão
83
+ mudou e a tag aponta para outro commit, a automação falha para revisão.
84
+ Versões inferiores às já registradas também não são publicadas automaticamente.
85
+ Se um push cancelar o CI anterior e a versão ainda não tiver tag, o próximo
86
+ CI aprovado da branch principal poderá publicar a versão no seu próprio commit.
80
87
 
81
88
  O [workflow de release][release] localiza o CI aprovado de um push na branch
82
89
  principal, confere o commit e baixa seu artifact por ID. Verifica procedência,
@@ -85,9 +92,10 @@ Também envia o mesmo pacote ao [npm público e GitHub Packages](packages.md),
85
92
  confere a integridade nos dois destinos e testa versão, instalação, diagnóstico
86
93
  e sincronização. O consumo pelo npm é anônimo; pelo GitHub, autenticado.
87
94
 
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.
95
+ A tag é a origem da publicação: `v0.0.21`, a release `v0.0.21` e os pacotes
96
+ `0.0.21` apontam para a mesma entrega. A execução manual também exige a tag;
97
+ não crie outra tag para uma versão nem mova uma tag já publicada. Publicações
98
+ feitas fora desse fluxo não criam tags automaticamente.
91
99
 
92
100
  Para consultar o código de uma versão sem trocar os arquivos atuais:
93
101
 
@@ -124,6 +132,9 @@ Se a release ainda não existe, use **Re-run failed jobs** ou
124
132
  `gh workflow run release.yml --ref vX.Y.Z` com a tag real. A publicação prioriza
125
133
  o secret `NPMJS_TOKEN` do repositório; sem ele, usa OIDC, conforme
126
134
  [publicação](packages.md).
135
+ Se a criação da tag ou o acionamento falhar, consulte a execução **Automatic
136
+ release tag**. Uma tag no commit correto pode ser retomada sem movê-la; se ela
137
+ apontar para outro commit, investigue antes de qualquer publicação.
127
138
 
128
139
  Se a release já existe e falta somente o pacote em um registro, use o modo
129
140
  `package_only` descrito em [pacote npm](packages.md#publicar-pelo-actions).
@@ -27,6 +27,9 @@ description: Incrementos verificáveis do AgentDevKit.
27
27
  - [Ignore do projeto](project-ignore/SPEC.md): preservar o arquivo da raiz.
28
28
  - [Ignore de clientes](client-ignore/SPEC.md): padrão local e exceções do projeto.
29
29
  - [Ferramenta e versões](tool-release/SPEC.md): atualização segura e tags de release.
30
+ - [Tags automáticas](automatic-release-tags/SPEC.md): publicação após CI aprovada.
31
+ - [Descoberta e preservação](discovery-preservation/SPEC.md): rotas de skills,
32
+ sincronização segura e validação em projetos reais.
30
33
 
31
34
  Cada incremento vincula requisitos, critérios, testes e evidências locais.
32
35