@pathbit/agentdevkit 0.0.21 → 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.
@@ -45,7 +45,8 @@ representa categorias opcionais; `nome` é o identificador único do recurso.
45
45
  Os caminhos são relativos à pasta do cliente, exceto onde indicado.
46
46
 
47
47
  - Codex: `.agents/skills/grupo/nome/SKILL.md` e referências agrupadas em
48
- `.codex`. `AGENTS.md` apresenta o hub; demais tipos são referências.
48
+ `.codex`. Se a rota principal de uma skill estiver obstruída, ela usa
49
+ `.codex/skills` como alternativa. `AGENTS.md` apresenta o hub.
49
50
  - Claude: `skills/nome/SKILL.md`, `commands/grupo/nome.md`,
50
51
  `rules/grupo/nome/RULE.md` e `agents/nome.md`.
51
52
  - Cursor: `skills/grupo/nome/SKILL.md`, `rules/grupo/nome.mdc`,
@@ -91,12 +92,21 @@ Prompts `.prompt.md` pertencem ao harness Local do VS Code, anunciado para
91
92
  remoção em fevereiro de 2027. No Copilot, use skills para rotinas invocáveis.
92
93
  `GEMINI.md` importa `AGENTS.md`; `CLAUDE.md` aponta para esse mesmo guia.
93
94
 
95
+ Com Claude e Copilot selecionados, o Copilot CLI pode receber referências
96
+ equivalentes de `.claude/rules` e `.github/instructions`. As duas rotas são
97
+ mantidas para seus respectivos clientes; o suporte a instruções varia entre
98
+ [ambientes do Copilot](https://docs.github.com/en/copilot/reference/custom-instructions-support).
99
+
94
100
  O instalador gera a seleção e a política em `espelhos.conf`; `sincronizar`
95
101
  reconstrói os apontamentos somente com Node e os arquivos do hub instalado.
96
102
  `--check` não altera arquivos. `--force` faz backup ignorado ao reparar
97
103
  conflitos gerenciados. Recursos e links humanos dos clientes são preservados;
98
104
  uma rota preservada não recebe apontamentos novos. No Zod, `.codex/skills`
99
105
  continua apontando para suas skills e o Codex recebe o kit em `.agents/skills`.
106
+ Quando a rota principal volta a ficar disponível, apontamentos alternativos
107
+ obsoletos só são removidos com propriedade comprovada. Conteúdo humano e o
108
+ índice Git são preservados; se ambas as rotas estiverem obstruídas, a operação
109
+ informa o conflito sem substituí-las.
100
110
 
101
111
  As fontes confirmam formatos, não execução de cada cliente. Valide descoberta,
102
112
  invocação e resultado no cliente escolhido antes de considerar uma integração
@@ -1,7 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
5
7
  import { diagnose } from '../SCRIPT.mjs';
6
8
  import { findExecutable, projectRoot, requireNode } from '../../lib/runtime.mjs';
7
9
 
@@ -28,3 +30,60 @@ test('requisito comum recusa Node antigo e procura executaveis sem shell', t =>
28
30
  assert.equal(findExecutable('fixture', { PATH: dirname(file) }), file);
29
31
  assert.equal(findExecutable('missing', { PATH: dirname(file) }), null);
30
32
  });
33
+
34
+ test('isMain reconhece caminho principal por alias sem executar imports como entrada', t => {
35
+ const output = join(projectRoot, '.agent-tests'); mkdirSync(output, { recursive: true });
36
+ const root = mkdtempSync(join(output, 'runtime alias espaço-'));
37
+ t.after(() => rmSync(root, { recursive: true, force: true }));
38
+ const real = join(root, 'real'), alias = join(root, 'alias'); mkdirSync(real);
39
+ try { symlinkSync(real, alias, process.platform === 'win32' ? 'junction' : 'dir'); }
40
+ catch (error) {
41
+ if (!['EPERM', 'EACCES'].includes(error.code)) throw error;
42
+ t.skip('Sistema sem permissão para criar alias de diretório.'); return;
43
+ }
44
+ const script = join(real, 'entry.mjs');
45
+ const runtime = pathToFileURL(join(projectRoot, '.pbai4devs/scripts/lib/runtime.mjs')).href;
46
+ writeFileSync(script, `import { isMain } from ${JSON.stringify(runtime)};\nconst main = isMain(import.meta.url);\nconsole.log(main ? 'entrada' : 'importado');\nif (main && process.env.ADK_RUNTIME_IMPORT) { const target = process.env.ADK_RUNTIME_IMPORT; delete process.env.ADK_RUNTIME_IMPORT; await import(target); }\n`);
47
+ const run = (args, extra = {}) => {
48
+ const env = { ...process.env, ...extra }; if (!Object.hasOwn(extra, 'NODE_OPTIONS')) delete env.NODE_OPTIONS;
49
+ if (!Object.hasOwn(extra, 'NODE_PRESERVE_SYMLINKS_MAIN')) delete env.NODE_PRESERVE_SYMLINKS_MAIN;
50
+ if (!Object.hasOwn(extra, 'NODE_PRESERVE_SYMLINKS')) delete env.NODE_PRESERVE_SYMLINKS;
51
+ const result = spawnSync(process.execPath, args, { encoding: 'utf8', env, timeout: 30000, windowsHide: true });
52
+ assert.equal(result.status, 0, result.stderr); return result.stdout.trim().split(/\r?\n/);
53
+ };
54
+ assert.deepEqual(run([script]), ['entrada']);
55
+ assert.deepEqual(run([join(alias, 'entry.mjs')]), ['entrada']);
56
+ const fileAlias = join(root, 'entry-link.mjs');
57
+ try {
58
+ symlinkSync(script, fileAlias, 'file');
59
+ assert.deepEqual(run([fileAlias]), ['entrada']);
60
+ } catch (error) { if (!['EPERM', 'EACCES'].includes(error.code)) throw error; }
61
+ const importer = join(root, 'importer.mjs');
62
+ writeFileSync(importer, `await import(${JSON.stringify(pathToFileURL(join(alias, 'entry.mjs')).href)});\n`);
63
+ assert.deepEqual(run([importer]), ['importado']);
64
+ writeFileSync(importer, `await import(${JSON.stringify(pathToFileURL(script).href + '?importado')});\n`);
65
+ assert.deepEqual(run([importer]), ['importado']);
66
+ assert.deepEqual(run(['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(script).href)});`, script]), ['importado']);
67
+ assert.deepEqual(run(['--input-type=module', `--eval=await import(${JSON.stringify(pathToFileURL(script).href)});`, script]), ['importado']);
68
+ const physical = pathToFileURL(script).href, logical = pathToFileURL(join(alias, 'entry.mjs')).href;
69
+ assert.deepEqual(run(['--preserve-symlinks-main', join(alias, 'entry.mjs')], { ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
70
+ assert.deepEqual(run(['--preserve-symlinks-main=false', join(alias, 'entry.mjs')], { ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
71
+ assert.deepEqual(run(['--preserve-symlinks-main=ignored', join(alias, 'entry.mjs')], { ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
72
+ assert.deepEqual(run(['--preserve-symlinks', script], { ADK_RUNTIME_IMPORT: logical }), ['entrada', 'importado']);
73
+ assert.deepEqual(run(['--preserve-symlinks', join(alias, 'entry.mjs')], { ADK_RUNTIME_IMPORT: logical }), ['entrada', 'importado']);
74
+ assert.deepEqual(run([join(alias, 'entry.mjs')], { NODE_OPTIONS: '--preserve-symlinks', ADK_RUNTIME_IMPORT: logical }), ['entrada', 'importado']);
75
+ assert.deepEqual(run([join(alias, 'entry.mjs')], { NODE_OPTIONS: '--preserve-symlinks-main', ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
76
+ assert.deepEqual(run(['--no-preserve-symlinks-main', join(alias, 'entry.mjs')], { NODE_OPTIONS: '--preserve-symlinks-main' }), ['entrada']);
77
+ assert.deepEqual(run([join(alias, 'entry.mjs')], { NODE_PRESERVE_SYMLINKS_MAIN: '1', ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
78
+ assert.deepEqual(run(['--no-preserve-symlinks-main', join(alias, 'entry.mjs')], { NODE_PRESERVE_SYMLINKS_MAIN: '1' }), ['entrada']);
79
+ const positiveText = join(root, 'preload --preserve-symlinks-main');
80
+ const negativeText = join(root, 'preload --no-preserve-symlinks-main');
81
+ for (const file of [positiveText, negativeText]) writeFileSync(file, '// preload sem efeitos\n');
82
+ assert.deepEqual(run(['--require', positiveText, join(alias, 'entry.mjs')]), ['entrada']);
83
+ assert.deepEqual(run([join(alias, 'entry.mjs')], { NODE_OPTIONS: `--require ${JSON.stringify(positiveText)}` }), ['entrada']);
84
+ assert.deepEqual(run(['--preserve-symlinks-main', '--require', negativeText, join(alias, 'entry.mjs')], { ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
85
+ assert.deepEqual(run([join(alias, 'entry.mjs')], { NODE_OPTIONS: `--preserve-symlinks-main --require ${JSON.stringify(negativeText)}`, ADK_RUNTIME_IMPORT: physical }), ['entrada', 'importado']);
86
+ const toolAlias = join(root, 'tool');
87
+ symlinkSync(join(projectRoot, '.pbai4devs/scripts/gerenciar-skills-externas'), toolAlias, process.platform === 'win32' ? 'junction' : 'dir');
88
+ assert.match(run([join(toolAlias, 'SCRIPT.mjs'), '--help']).join('\n'), /Uso:/);
89
+ });
@@ -6,15 +6,16 @@ import { isGeneratedOutput } from './gitignore.mjs';
6
6
 
7
7
  const policy = JSON.parse(readFileSync(new URL('../../templates/clients/ADAPTERS.json', import.meta.url), 'utf8'));
8
8
  const kinds = new Set(['skills', 'agents', 'commands', 'rules']);
9
- const layouts = new Set(['agrupado', 'plano', 'arquivo', 'mdc', 'agente', 'prompt', 'instrucoes']);
9
+ const layouts = new Set(['agrupado', 'plano', 'arquivo', 'mdc', 'agente', 'prompt', 'instrucoes', 'nenhum']);
10
10
  const entryNames = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
11
11
  const safeRoot = value => typeof value === 'string' && /^\.[a-z0-9][a-z0-9-]*$/.test(value) && !['.git', '.pbai4devs', '.agent-tests'].includes(value);
12
12
  const stringList = value => Array.isArray(value) && value.every(item => typeof item === 'string');
13
13
  if (policy.schemaVersion !== 1 || !Array.isArray(policy.adapters) || !policy.adapters.length || !stringList(policy.entrypoints) || policy.entrypoints.some(name => !entryNames.has(name))) throw new Error('Politica de clientes invalida.');
14
14
  const ids = new Set(), roots = new Set(), aliases = {};
15
15
  for (const adapter of policy.adapters) {
16
- if (!adapter || typeof adapter.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(adapter.id) || adapter.id === 'all' || ids.has(adapter.id) || typeof adapter.name !== 'string' || !safeRoot(adapter.root) || roots.has(adapter.root) || !adapter.formats || Object.entries(adapter.formats).some(([kind, layout]) => !kinds.has(kind) || !layouts.has(layout))) throw new Error('Adaptador invalido.');
16
+ if (!adapter || typeof adapter.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(adapter.id) || adapter.id === 'all' || ids.has(adapter.id) || typeof adapter.name !== 'string' || !safeRoot(adapter.root) || roots.has(adapter.root) || !adapter.formats || Object.entries(adapter.formats).some(([kind, layout]) => !kinds.has(kind) || !layouts.has(layout) || layout === 'nenhum' && kind !== 'skills')) throw new Error('Adaptador invalido.');
17
17
  if (/[\r\n]/.test(adapter.name) || typeof adapter.reason !== 'string' || /[\r\n]/.test(adapter.reason) || (adapter.discovery !== undefined && adapter.discovery !== 'antigravity')) throw new Error('Descricao ou descoberta de adaptador invalida.');
18
+ if (adapter.skillFallback !== undefined && (!safeRoot(adapter.skillFallback) || adapter.skillFallback === adapter.root || adapter.formats.skills !== 'nenhum')) throw new Error('Fallback de skills invalido.');
18
19
  for (const field of ['aliases', 'requires', 'extraRoots', 'entrypoints', 'sources']) if (!stringList(adapter[field])) throw new Error('Lista invalida no adaptador: ' + field);
19
20
  if (adapter.extraRoots.some(root => !safeRoot(root)) || adapter.entrypoints.some(name => !entryNames.has(name)) || adapter.sources.some(url => !url.startsWith('https://'))) throw new Error('Caminho ou fonte invalida no adaptador.');
20
21
  for (const field of ['agentMetadata', 'ruleMetadata']) if (adapter[field] !== undefined && !/^[a-z0-9-]+\.ya?ml$/.test(adapter[field])) throw new Error('Metadados invalidos no adaptador.');
@@ -22,6 +23,7 @@ for (const adapter of policy.adapters) {
22
23
  }
23
24
  for (const adapter of policy.adapters) {
24
25
  if (adapter.requires.some(id => !ids.has(id))) throw new Error('Dependencia de adaptador desconhecida.');
26
+ if (adapter.skillFallback && !policy.adapters.some(other => other.root === adapter.skillFallback && adapter.requires.includes(other.id))) throw new Error('Fallback exige cliente de origem.');
25
27
  for (const alias of adapter.aliases) {
26
28
  if (!/^[a-z][a-z0-9-]*$/.test(alias) || ids.has(alias) || alias === 'all' || Object.hasOwn(aliases, alias)) throw new Error('Alias de adaptador invalido.');
27
29
  aliases[alias] = adapter.id;
@@ -102,6 +104,7 @@ export function adapterManifest(root, { clients } = {}) {
102
104
  for (const adapter of selection.adapters) {
103
105
  rows.push(`# ${adapter.id}: ${adapter.reason}`, `agente|${adapter.root}|agrupado`);
104
106
  for (const [kind, mode] of Object.entries(adapter.formats)) rows.push(`formato|${adapter.root}|${kind}|${mode}`);
107
+ if (adapter.skillFallback) rows.push(`fallback|${adapter.root}|${adapter.skillFallback}|skills`);
105
108
  if (adapter.discovery) rows.push(`descoberta|${adapter.root}|${adapter.discovery}`);
106
109
  for (const [kind, key] of [['agents', 'agentMetadata'], ['rules', 'ruleMetadata']]) {
107
110
  if (!adapter[key]) continue;
@@ -1,6 +1,6 @@
1
1
  import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
2
2
  import { fileURLToPath, pathToFileURL } from 'node:url';
3
- import { accessSync, constants, statSync } from 'node:fs';
3
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
4
4
 
5
5
  export const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
6
6
 
@@ -10,8 +10,44 @@ export function requireNode(minimum = 24, version = process.versions.node) {
10
10
  }
11
11
  }
12
12
 
13
+ // NODE_OPTIONS uses Node's double-quoted token syntax, not shell quoting.
14
+ // Keep option values as a single token so text in a preload path is not a flag.
15
+ function nodeOptionTokens(value) {
16
+ const result = []; let token = '', quoted = false, escaped = false, active = false;
17
+ for (const character of value) {
18
+ if (escaped) { token += character; escaped = false; active = true; }
19
+ else if (character === '\\' && quoted) escaped = true;
20
+ else if (character === '"') { quoted = !quoted; active = true; }
21
+ else if (character === ' ' && !quoted) { if (active) result.push(token); token = ''; active = false; }
22
+ else { token += character; active = true; }
23
+ }
24
+ if (escaped) token += '\\';
25
+ if (active) result.push(token);
26
+ return result;
27
+ }
28
+
29
+ function preserveMainSymlinks() {
30
+ let preserved = process.env.NODE_PRESERVE_SYMLINKS_MAIN === '1';
31
+ for (const tokens of [nodeOptionTokens(process.env.NODE_OPTIONS || ''), process.execArgv]) {
32
+ for (const token of tokens) {
33
+ if (/^--preserve-symlinks-main(?:=.*)?$/.test(token)) preserved = true;
34
+ else if (/^--no-preserve-symlinks-main(?:=.*)?$/.test(token)) preserved = false;
35
+ }
36
+ }
37
+ return preserved;
38
+ }
39
+
13
40
  export function isMain(metaUrl) {
14
- return Boolean(process.argv[1]) && metaUrl === pathToFileURL(resolve(process.argv[1])).href;
41
+ if (!process.argv[1] || typeof metaUrl !== 'string') return false;
42
+ // In eval/print mode argv[1] is a user argument, not the entry module.
43
+ if (process.execArgv.some(arg => /^-(?:e|p|ep|pe)$/.test(arg) || /^--(?:eval|print)(?:=|$)/.test(arg))) return false;
44
+ const entry = resolve(process.argv[1]);
45
+ // Only --preserve-symlinks-main keeps the main URL logical. The separate
46
+ // --preserve-symlinks option can preserve an imported alias while the main
47
+ // URL is physical, so comparing both paths would execute that import twice.
48
+ if (preserveMainSymlinks()) return metaUrl === pathToFileURL(entry).href;
49
+ try { return metaUrl === pathToFileURL(realpathSync(entry)).href; }
50
+ catch { return false; }
15
51
  }
16
52
 
17
53
  export function findExecutable(name, environment = process.env, platform = process.platform) {
@@ -29,7 +29,9 @@ catálogo; resultados, caches e pastas temporárias não entram nos índices.
29
29
  `formato|pasta|tipo|formato` e registrar `descoberta|.agents|antigravity`.
30
30
  `agrupado` preserva a árvore; `plano` retira categorias; `arquivo` preserva
31
31
  categorias e usa `nome.md`. `mdc`, `instrucoes`, `prompt` e `agente` produzem as
32
- extensões nativas. A descoberta gera os três manifestos JSON do Antigravity.
32
+ extensões nativas; `nenhum` desativa a derivação de skills naquela rota.
33
+ `fallback|destino|origem|skills` usa o destino para cada skill cuja rota de
34
+ origem estiver obstruída. A descoberta gera os três manifestos JSON do Antigravity.
33
35
  Veja os motivos e limites em [Compatibilidade](../../COMPATIBILIDADE.md).
34
36
 
35
37
  Sem manifesto, o sincronizador gera a política de
@@ -49,3 +51,5 @@ atualizar arquivos gerados e blocos separadamente, preservando alterações huma
49
51
  Apontamentos obsoletos podem ser removidos quando a propriedade é comprovada;
50
52
  arquivos e pastas próprios, inclusive os adicionais dentro do mesmo recurso,
51
53
  devem permanecer.
54
+ O estado derivado registra remoções comprovadas para reconciliá-las no próximo
55
+ `update`; campos adicionais existentes no estado são preservados.
@@ -21,7 +21,7 @@ const auxiliary = ['templates', 'scripts', 'tools', 'external-skills', 'state',
21
21
  const reserved = new Set(['.git', '.pbai4devs', '.agent-tests']);
22
22
  const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
23
23
  const sorted = values => [...new Set(values)].sort(compare);
24
- const layouts = new Set(['agrupado', 'plano', 'arquivo', 'mdc', 'instrucoes', 'prompt', 'agente']);
24
+ const layouts = new Set(['agrupado', 'plano', 'arquivo', 'mdc', 'instrucoes', 'prompt', 'agente', 'nenhum']);
25
25
  const fail = message => { throw new Error(message); };
26
26
  const normalize = text => text.replace(/\r\n?/g, '\n');
27
27
  const read = file => normalize(readFileSync(file, 'utf8'));
@@ -30,6 +30,77 @@ const stat = file => {
30
30
  catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; }
31
31
  };
32
32
 
33
+ function replaceJsonMember(bytes, member, value) {
34
+ const text = bytes.toString('utf8');
35
+ if (!Buffer.from(text, 'utf8').equals(bytes)) fail('Invalid UTF-8 in derived state.');
36
+ let i = 0;
37
+ const space = () => { while (/\s/.test(text[i] || '') && i < text.length) i++; };
38
+ const stringEnd = () => {
39
+ if (text[i++] !== '"') fail('Invalid JSON member.');
40
+ while (i < text.length) {
41
+ const char = text[i++];
42
+ if (char === '\\') { i++; continue; }
43
+ if (char === '"') return;
44
+ }
45
+ fail('Unterminated JSON string.');
46
+ };
47
+ space(); if (text[i++] !== '{') fail('Invalid derived state.');
48
+ const properties = [];
49
+ while (true) {
50
+ space(); if (text[i] === '}') break;
51
+ const start = i; stringEnd(); const key = JSON.parse(text.slice(start, i));
52
+ space(); if (text[i++] !== ':') fail('Invalid JSON member.');
53
+ space(); const valueStart = i;
54
+ if (text[i] === '"') stringEnd();
55
+ else if (text[i] === '{' || text[i] === '[') {
56
+ const stack = [];
57
+ do {
58
+ const char = text[i];
59
+ if (char === '"') { stringEnd(); continue; }
60
+ i++;
61
+ if (char === '{' || char === '[') stack.push(char);
62
+ else if (char === '}' || char === ']') stack.pop();
63
+ } while (stack.length && i < text.length);
64
+ if (stack.length) fail('Invalid derived state.');
65
+ } else { while (i < text.length && text[i] !== ',' && text[i] !== '}') i++; while (i > valueStart && /\s/.test(text[i - 1])) i--; }
66
+ const end = i; properties.push({ key, start, valueStart, end });
67
+ space(); if (text[i] === ',') { i++; continue; } if (text[i] === '}') break;
68
+ fail('Invalid JSON member separator.');
69
+ }
70
+ const matches = properties.filter(item => item.key === member);
71
+ if (matches.length > 1) fail('Ambiguous derived state member.');
72
+ if (!matches.length) {
73
+ if (value === undefined) return bytes;
74
+ const insertion = `${properties.length ? ',' : ''}\n ${JSON.stringify(member)}: ${JSON.stringify(value, null, 2)}\n`;
75
+ const next = text.slice(0, i) + insertion + text.slice(i);
76
+ try { JSON.parse(next); } catch { fail('Unable to preserve derived state safely.'); }
77
+ return Buffer.from(next);
78
+ }
79
+ const current = matches[0], index = properties.indexOf(current);
80
+ let next;
81
+ if (value !== undefined) next = text.slice(0, current.valueStart) + JSON.stringify(value, null, 2) + text.slice(current.end);
82
+ else if (index > 0) next = text.slice(0, properties[index - 1].end) + text.slice(current.end);
83
+ else if (properties.length > 1) next = text.slice(0, current.start) + text.slice(properties[1].start);
84
+ else next = text.slice(0, current.start) + text.slice(current.end);
85
+ try { JSON.parse(next); } catch { fail('Unable to preserve derived state safely.'); }
86
+ return Buffer.from(next);
87
+ }
88
+
89
+
90
+ // Compare owned fields structurally, preserving all other JSON bytes.
91
+ function derivedContent(original, previous, next) {
92
+ if (!original) return JSON.stringify(next, null, 2) + '\n';
93
+ const keys = ['schemaVersion', 'entries', 'outputs', 'files', 'blocks', 'removedBlocks', 'removedFiles'];
94
+ const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
95
+ ? Object.fromEntries(Object.keys(value).sort(compare).map(key => [key, canonical(value[key])]))
96
+ : Array.isArray(value) ? value.map(canonical) : value;
97
+ let result = original;
98
+ for (const key of keys) {
99
+ if (JSON.stringify(canonical(previous[key])) !== JSON.stringify(canonical(next[key]))) result = replaceJsonMember(result, key, next[key]);
100
+ }
101
+ return result.toString('utf8');
102
+ }
103
+
33
104
  export function header(text) {
34
105
  const lines = normalize(text).split('\n');
35
106
  if (lines[0] !== '---') fail('Frontmatter YAML ausente.');
@@ -76,7 +147,7 @@ function link(target, source) {
76
147
 
77
148
  export { resourcePath } from '../lib/resource-path.mjs';
78
149
 
79
- export function synchronize({ root = projectRoot, check = false, force = false, localIgnores = true, generateEntrypoints = true, recordDerivedState = true, output = console.log } = {}) {
150
+ export function synchronize({ root = projectRoot, check = false, force = false, localIgnores = true, generateEntrypoints = true, recordDerivedState = true, outputBlocked, output = console.log } = {}) {
80
151
  requireNode();
81
152
  root = realpathSync(resolve(root));
82
153
  const absolute = value => join(root, ...value.split('/'));
@@ -156,6 +227,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
156
227
  const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
157
228
  const agents = new Map();
158
229
  const formats = new Map();
230
+ const fallbacks = new Map();
159
231
  const discoveries = new Set();
160
232
  const ignoreRoots = new Set();
161
233
  const natives = [];
@@ -182,14 +254,20 @@ export function synchronize({ root = projectRoot, check = false, force = false,
182
254
  && !['readme.md', 'instructions.md'].includes(posix.basename(path).toLowerCase())
183
255
  && (/\.(md|mdc)$/i.test(path) || path.startsWith('.gemini/commands/') && path.endsWith('.toml'));
184
256
  };
257
+ const validIgnoreBlock = path => typeof path === 'string' && /^\.[A-Za-z0-9][A-Za-z0-9._-]*\/\.gitignore$/.test(path) && !reserved.has(path.split('/')[0].toLowerCase());
185
258
  for (const [name, values] of [['files', previousEntries.files ?? {}], ['blocks', previousEntries.blocks ?? {}]]) {
186
- if (!values || typeof values !== 'object' || Array.isArray(values) || Object.entries(values).some(([path, hash]) => !(validResourcePath(path) || name === 'blocks' && canonicalEntrypoint(path)) || !/^[a-f0-9]{64}$/.test(hash))) fail(`Registro de ${name} derivados invalido.`);
259
+ if (!values || typeof values !== 'object' || Array.isArray(values) || Object.entries(values).some(([path, hash]) => !(validResourcePath(path) || name === 'blocks' && (canonicalEntrypoint(path) || validIgnoreBlock(path))) || !/^[a-f0-9]{64}$/.test(hash))) fail(`Registro de ${name} derivados invalido.`);
187
260
  for (const path of Object.keys(values)) safe(canonicalEntrypoint(path) ? resolveEntrypoint(root, canonicalEntrypoint(path)) : path);
188
261
  }
189
262
  const removedBlocks = new Set(previousEntries.removedBlocks ?? []);
190
263
  if (!Array.isArray(previousEntries.removedBlocks ?? []) || [...removedBlocks].some(path => typeof path !== 'string' || !(validResourcePath(path) || canonicalEntrypoint(path)))) fail('Registro de blocos removidos invalido.');
191
264
  for (const path of removedBlocks) safe(canonicalEntrypoint(path) ? resolveEntrypoint(root, canonicalEntrypoint(path)) : path);
192
- const priorFiles = { ...installed?.files, ...previousEntries.files };
265
+ const removedFiles = previousEntries.removedFiles ?? {};
266
+ if (!removedFiles || typeof removedFiles !== 'object' || Array.isArray(removedFiles) || Object.entries(removedFiles).some(([path, hash]) => !validResourcePath(path) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de arquivos removidos invalido.');
267
+ if (Object.entries(removedFiles).some(([path, hash]) => installed?.files?.[path] && installed.files[path] !== hash)) fail('Registro de arquivos removidos diverge do estado instalado.');
268
+ for (const path of Object.keys(removedFiles)) safe(path);
269
+ const effectiveRemovedFiles = Object.fromEntries(Object.entries(removedFiles).filter(([path, hash]) => installed?.files?.[path] === hash));
270
+ const priorFiles = { ...Object.fromEntries(Object.entries(installed?.files ?? {}).filter(([path, hash]) => effectiveRemovedFiles[path] !== hash)), ...previousEntries.files };
193
271
  const priorBlocks = { ...Object.fromEntries(Object.entries(installed?.blocks ?? {}).filter(([path]) => !removedBlocks.has(path))), ...previousEntries.blocks };
194
272
  const ownedPaths = new Set([...Object.keys(priorFiles), ...Object.keys(priorBlocks), ...Object.keys(previousEntries.entries), ...Object.keys(previousOutputs)]);
195
273
  const nativeRoots = new Set(rows.map(row => row.split('|')).filter(([type]) => ['agente', 'gitignore'].includes(type)).map(([, target]) => target));
@@ -200,6 +278,28 @@ export function synchronize({ root = projectRoot, check = false, force = false,
200
278
  const boundary = nativePathBoundary(root, value, ownedPaths, { preserveRootAliases: true });
201
279
  return boundary ? remember(boundary.path, boundary.reason) : false;
202
280
  }
281
+ const fallbackChecks = new Map();
282
+ function blockedOutput(path) {
283
+ if (outputBlocked) return outputBlocked(path);
284
+ let boundary = nativePathBoundary(root, path, ownedPaths, { preserveRootAliases: true });
285
+ if (!boundary) {
286
+ const info = stat(safe(path));
287
+ if (info && !info.isFile()) boundary = { path, reason: 'non-file' };
288
+ }
289
+ if (!boundary) return { blocked: false };
290
+ remember(boundary.path, boundary.reason);
291
+ const info = lstatSync(absolute(boundary.path));
292
+ return { blocked: true, ...boundary, identity: [info.dev, info.ino, info.mode, info.size, info.mtimeMs, info.ctimeMs] };
293
+ }
294
+ function unavailable(path) {
295
+ const status = blockedOutput(path);
296
+ if (fallbackChecks.has(path) && JSON.stringify(fallbackChecks.get(path)) !== JSON.stringify(status)) fail(`Caminho de skill mudou durante o planejamento: ${path}`);
297
+ fallbackChecks.set(path, status);
298
+ return status.blocked;
299
+ }
300
+ function assertFallbackChecks() {
301
+ for (const [path, status] of fallbackChecks) if (JSON.stringify(blockedOutput(path)) !== JSON.stringify(status)) fail(`Caminho de skill mudou apos o planejamento: ${path}`);
302
+ }
203
303
  function claimed(value) {
204
304
  if (ownedPaths.has(value)) return true;
205
305
  const info = stat(safe(value));
@@ -241,7 +341,8 @@ export function synchronize({ root = projectRoot, check = false, force = false,
241
341
  agents.set(target, mode);
242
342
  } else if (type === 'formato') {
243
343
  if (fields.length !== 4 || !Object.hasOwn(principals, source) || !layouts.has(metadata)) fail(`Formato invalido: ${row}`);
244
- if (source === 'skills' && !['agrupado', 'plano'].includes(metadata)) fail(`Skills exigem formato agrupado ou plano: ${row}`);
344
+ if (source === 'skills' && !['agrupado', 'plano', 'nenhum'].includes(metadata)) fail(`Skills exigem formato agrupado, plano ou nenhum: ${row}`);
345
+ if (metadata === 'nenhum' && source !== 'skills') fail(`Formato incompativel com recurso: ${row}`);
245
346
  if ((metadata === 'mdc' || metadata === 'instrucoes') && source !== 'rules' || metadata === 'prompt' && source !== 'commands' || metadata === 'agente' && source !== 'agents') fail(`Formato incompativel com recurso: ${row}`);
246
347
  const key = `${target}/${source}`;
247
348
  if (formats.has(key)) fail(`Formato duplicado: ${key}`);
@@ -249,6 +350,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
249
350
  } else if (type === 'descoberta') {
250
351
  if (fields.length !== 3 || target !== '.agents' || source !== 'antigravity' || discoveries.has(target)) fail(`Descoberta invalida: ${row}`);
251
352
  discoveries.add(target);
353
+ } else if (type === 'fallback') {
354
+ if (fields.length !== 4 || metadata !== 'skills' || target === source || fallbacks.has(target)) fail(`Fallback invalido: ${row}`);
355
+ fallbacks.set(target, source);
252
356
  } else if (type === 'gitignore') {
253
357
  if (fields.length !== 2) fail('gitignore aceita somente a pasta de destino.');
254
358
  ignoreRoot(target);
@@ -269,6 +373,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
269
373
  }
270
374
  if (!agents.size) fail('Nenhum agente cadastrado.');
271
375
  for (const key of formats.keys()) if (!agents.has(key.split('/')[0])) fail(`Formato fora dos agentes: ${key}`);
376
+ for (const [target, source] of fallbacks) if (!agents.has(target) || !agents.has(source) || formats.get(`${target}/skills`) !== 'nenhum' || formats.get(`${source}/skills`) === 'nenhum') fail(`Fallback fora dos agentes: ${target}`);
272
377
  for (const root of discoveries) if (!agents.has(root)) fail(`Descoberta fora dos agentes: ${root}`);
273
378
  const managed = value => [...agents.keys()].some(agent => value.startsWith(agent + '/'));
274
379
  const built = buildCatalog(root);
@@ -287,7 +392,19 @@ export function synchronize({ root = projectRoot, check = false, force = false,
287
392
  }
288
393
  for (const [source, kind] of sources) {
289
394
  for (const [agent, mode] of agents) {
290
- let path = resourcePath(source, kind, formats.get(`${agent}/${kind}`) || mode);
395
+ const format = formats.get(`${agent}/${kind}`) || mode;
396
+ if (format === 'nenhum') {
397
+ const primary = kind === 'skills' && fallbacks.get(agent);
398
+ if (!primary) continue;
399
+ const primaryFormat = formats.get(`${primary}/${kind}`) || agents.get(primary);
400
+ const primaryTarget = `${primary}/${resourcePath(source, kind, primaryFormat)}`;
401
+ if (!unavailable(primaryTarget)) continue;
402
+ const target = `${agent}/${resourcePath(source, kind, mode)}`;
403
+ if (unavailable(target)) fail(`Skill indisponivel: ${source}; caminhos ${primaryTarget} e ${target} obstruidos.`);
404
+ add(target, source);
405
+ continue;
406
+ }
407
+ let path = resourcePath(source, kind, format);
291
408
  if (agent === '.gemini' && kind === 'commands') path = path.replace(/\.md$/, '.toml');
292
409
  add(`${agent}/${path}`, source);
293
410
  }
@@ -385,7 +502,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
385
502
  const unchanged = current === null || priorHashes.includes(createHash('sha256').update(current).digest('hex'));
386
503
  if (!unchanged) { remember(manifest, 'edited-generated-file'); continue; }
387
504
  const nextHash = createHash('sha256').update(content).digest('hex');
388
- if (installed?.files?.[manifest] !== nextHash) discoveryOutputs[manifest] = nextHash;
505
+ if (installed?.files?.[manifest] !== nextHash || Object.hasOwn(previousOutputs, manifest)) discoveryOutputs[manifest] = nextHash;
389
506
  if (!info || readFileSync(absolute(manifest), 'utf8') !== content) {
390
507
  if (info && force) backup.add(manifest);
391
508
  registries.set(manifest, content);
@@ -416,6 +533,10 @@ export function synchronize({ root = projectRoot, check = false, force = false,
416
533
  merged = mergeGitignore(current, patterns);
417
534
  }
418
535
  catch (error) { fail(`${target}: ${error.message}`); }
536
+ if (target !== '.pbai4devs/.gitignore') {
537
+ const start = merged.text.indexOf(ignoreBegin), finish = merged.text.indexOf(ignoreEnd);
538
+ if (start >= 0 && finish > start) managedBlocks[target] = createHash('sha256').update(merged.text.slice(start, finish + ignoreEnd.length)).digest('hex');
539
+ }
419
540
  if (merged.text !== current) {
420
541
  ignoreFiles.set(target, merged.text);
421
542
  if (force && info && merged.hasBlock) backup.add(target);
@@ -568,8 +689,11 @@ export function synchronize({ root = projectRoot, check = false, force = false,
568
689
  if (Object.keys(derivedBlocks).length) next.blocks = derivedBlocks;
569
690
  for (const path of Object.keys(managedBlocks)) removedBlocks.delete(path);
570
691
  if (removedBlocks.size) next.removedBlocks = sorted(removedBlocks);
571
- const registryContent = JSON.stringify(next, null, 2) + '\n';
572
- if (recordDerivedState && (Object.keys(nextEntries).length || Object.keys(discoveryOutputs).length || Object.keys(generatedFiles).length || Object.keys(derivedBlocks).length || removedBlocks.size || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== registryContent)) registries.set(entryRegistry, registryContent);
692
+ for (const path of expected.keys()) delete effectiveRemovedFiles[path];
693
+ for (const path of remove) if (validResourcePath(path) && installed?.files?.[path] && !expected.has(path)) effectiveRemovedFiles[path] = installed.files[path];
694
+ if (Object.keys(effectiveRemovedFiles).length) next.removedFiles = Object.fromEntries(Object.entries(effectiveRemovedFiles).sort(([a], [b]) => compare(a, b)));
695
+ const registryContent = derivedContent(registryBytes, previousEntries, next);
696
+ if (recordDerivedState && (Object.keys(nextEntries).length || Object.keys(discoveryOutputs).length || Object.keys(generatedFiles).length || Object.keys(derivedBlocks).length || removedBlocks.size || Object.keys(effectiveRemovedFiles).length || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== registryContent)) registries.set(entryRegistry, registryContent);
573
697
  const ownedOutputs = sorted([...targets, ...built.outputs.keys(), '.pbai4devs/espelhos.conf', ...rootEntries.keys(), ...(recordDerivedState && (stat(safe(entryRegistry)) || registries.has(entryRegistry)) ? [entryRegistry] : []),
574
698
  ...[...discoveries].flatMap(agent => ['skills', 'rules', 'agents'].map(kind => `${agent}/${kind}.json`)),
575
699
  ...[...ignoreTemplates.keys()].filter(target => {
@@ -633,6 +757,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
633
757
  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 + (localStatus.status === 'no-git' ? 1 : 0) };
634
758
  const dirty = Object.values(summary).some(Boolean);
635
759
  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.`);
760
+ assertFallbackChecks();
636
761
  if (localStatus.status === 'no-git') output('Git ausente: apos git init, execute a sincronizacao para proteger os arquivos locais.');
637
762
  const preservedPaths = sorted(preserved.keys()).map(path => preserved.get(path));
638
763
  if (check) return { ...summary, ownedOutputs, managedBlocks, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
@@ -700,6 +825,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
700
825
  chmodSync(safe(value), content.mode);
701
826
  }
702
827
  try {
828
+ assertFallbackChecks();
703
829
  if (localPlan) assertLocalIgnoreUnchanged(localPlan);
704
830
  if (Object.keys(affectedHashes).length) {
705
831
  if (!assertGitReadableForChanges() && hadGit) fail('Git deixou de estar disponivel antes da sincronizacao; arquivos preservados.');
@@ -712,6 +838,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
712
838
  for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
713
839
  for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
714
840
  if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
841
+ assertFallbackChecks();
715
842
  // Directory ownership is not recorded: keep even empty parent directories.
716
843
  if (localPlan) applyLocalIgnore(localPlan);
717
844
  } catch (error) {
@@ -122,6 +122,36 @@ test('conteúdo humano concorrente não é apagado durante rollback', t => {
122
122
  assert.equal(fs.readFileSync(join(root, generated), 'utf8'), human);
123
123
  });
124
124
 
125
+ test('falha ao registrar remoção derivada restaura fallback e preserva índice', t => {
126
+ const root = fixture(t), fallback = '.codex/skills/demo/SKILL.md';
127
+ write(root, '.pbai4devs/espelhos.conf', [
128
+ 'agente|.agents|agrupado', 'agente|.codex|plano',
129
+ 'formato|.codex|skills|nenhum', 'fallback|.codex|.agents|skills',
130
+ ].join('\n') + '\n');
131
+ write(root, '.agents/skills', 'obstrução humana');
132
+ write(root, '.gitignore', '# ignore humano\n');
133
+ assert.equal(spawnSync('git', ['-C', root, 'add', '.gitignore']).status, 0);
134
+ run(root);
135
+ const initial = fs.readFileSync(join(root, fallback));
136
+ write(root, '.pbai4devs/state/agentdevkit.json', JSON.stringify({ schemaVersion: 1, files: { [fallback]: hash(initial) }, blocks: {} }));
137
+ fs.rmSync(join(root, '.pbai4devs/state/derived.json'), { force: true });
138
+ fs.rmSync(join(root, '.agents/skills')); // A obstrução pertence à fixture.
139
+ const before = snapshot(root), index = fs.readFileSync(join(root, '.git/index'));
140
+ const originalRename = fs.renameSync;
141
+ let injected = false;
142
+ t.mock.method(fs, 'renameSync', (from, to) => {
143
+ if (!injected && to === join(root, '.pbai4devs/state/derived.json')) { injected = true; throw new Error('falha injetada no registro derivado'); }
144
+ return originalRename(from, to);
145
+ });
146
+ syncBuiltinESMExports();
147
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
148
+ assert.throws(() => run(root), /falha injetada no registro derivado/);
149
+ assert.equal(injected, true);
150
+ assert.deepEqual(snapshot(root), before);
151
+ assert.deepEqual(fs.readFileSync(join(root, '.git/index')), index);
152
+ assert.deepEqual(fs.readFileSync(join(root, fallback)), initial);
153
+ });
154
+
125
155
  test('conteúdo humano é preservado quando a escrita lança erro após promover o arquivo', t => {
126
156
  const root = fixture(t), rename = fs.renameSync;
127
157
  const failure = new Error('falha depois de promover');
@@ -913,6 +913,9 @@ test('formatos desconhecidos ou fora do cliente falham antes de mutar', t => {
913
913
  for (const row of [
914
914
  'formato|.missing|skills|agrupado', 'formato|.codex|unknown|agrupado',
915
915
  'formato|.codex|skills|mdc', 'formato|.codex|rules|prompt',
916
+ 'formato|.codex|agents|nenhum', 'formato|.codex|commands|nenhum',
917
+ 'fallback|.codex|.missing|skills', 'fallback|.codex|.codex|skills',
918
+ 'fallback|.codex|.agents|rules',
916
919
  'formato|.codex|skills|plano\nformato|.codex|skills|agrupado',
917
920
  'descoberta|.codex|antigravity', 'descoberta|.agents|antigravity',
918
921
  ]) {
@@ -922,3 +925,22 @@ test('formatos desconhecidos ou fora do cliente falham antes de mutar', t => {
922
925
  assert.equal(resourcePath('.pbai4devs/commands/team/check/COMMAND.md', 'commands', 'arquivo'), 'commands/team/check.md');
923
926
  assert.throws(() => resourcePath(source, 'skills', 'arquivo'), /Skills/);
924
927
  });
928
+
929
+ test('sincronização direta recusa troca concorrente da obstrução de fallback', t => {
930
+ const root = fixture(t, true);
931
+ write(root, source, skill);
932
+ write(root, '.pbai4devs/espelhos.conf', [
933
+ 'agente|.agents|agrupado', 'agente|.codex|agrupado',
934
+ 'formato|.codex|skills|nenhum', 'fallback|.codex|.agents|skills',
935
+ ].join('\n') + '\n');
936
+ write(root, '.agents/skills/projeto', 'Obstrução humana.');
937
+ let called = false;
938
+ assert.throws(() => synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {
939
+ if (called) return;
940
+ called = true;
941
+ rmSync(join(root, '.agents/skills/projeto')); // Simula mudança humana após o planejamento.
942
+ } }), /mudou apos o planejamento/);
943
+ assert.equal(called, true);
944
+ assert.equal(existsSync(join(root, '.codex')), false);
945
+ assert.equal(existsSync(join(root, '.agents/skills/projeto/demo/SKILL.md')), false);
946
+ });
@@ -14,19 +14,21 @@ node .pbai4devs/scripts/testar-maturidade/SCRIPT.mjs run --case legado
14
14
  node --test .pbai4devs/scripts/testar-maturidade/tests/TEST.mjs
15
15
  ```
16
16
 
17
- `prepare` só gera as cópias; `run` gera e executa. Os oito cenários ficam em
18
- `examples/generated/`: agentes, descoberta, planejamento, aplicação inicial,
19
- configurada, manutenção, legado e monorepo com caminhos em português.
17
+ `prepare` só gera as cópias; `run` gera e executa. Cada execução cria uma nova
18
+ pasta `examples/generated/maturity-*` e informa seu caminho no console. Por
19
+ padrão são oito cenários: agentes, descoberta, planejamento, aplicação inicial,
20
+ configurada, manutenção, legado e monorepo com caminhos em português. Use
21
+ `--case` para executar apenas um.
20
22
 
21
23
  O harness copia recursos e configurações reais; exclui pacotes, caches, logs,
22
24
  resultados de testes, Playwright e pastas temporárias em qualquer caixa,
23
25
  `.git` e nomes conhecidos de credenciais/overrides locais. Não faz varredura
24
26
  semântica de segredos. A API também aceita `tmp/examples/` para compatibilidade
25
- e descendentes de `.agent-tests/`. Regenera apenas pastas identificadas como
26
- suas. Cada exemplo recebe Git local, sem commits nem identidade alterada.
27
- O diretório gerado contém um ignore próprio, inclusive em projetos consumidores.
28
- `prepare` redefine o relatório como `prepared`, sem conservar provas antigas.
29
- Se a preparação falhar, o índice fica em `preparing` e o relatório antigo sai.
27
+ e descendentes de `.agent-tests/`. Um destino explícito deve ser inexistente;
28
+ execuções e projetos anteriores não são substituídos nem removidos. Cada
29
+ exemplo recebe Git local, sem commits nem identidade alterada. O diretório
30
+ gerado contém um ignore próprio, inclusive em projetos consumidores. Se a
31
+ preparação falhar, o índice da nova execução permanece em `preparing`.
30
32
  O hub `.pbai4devs` e os clientes registrados são copiados, incluindo comandos
31
33
  nativos em TOML. Sem manifesto local, os templates definem os clientes em
32
34
  memória, respeitando a seleção salva. `.vscode` exige registro. Apenas as entradas
@@ -50,5 +52,5 @@ O gate positivo simula o parecer para testar seu contrato; não representa
50
52
  revisão humana. Estados de aprovação são dados sintéticos. Nenhum teste
51
53
  certifica produção, Docker, interface visual ou outro sistema operacional.
52
54
 
53
- Índice e relatório: `examples/generated/README.md` e `REPORT.json`. Logs e provas
54
- individuais ficam em `.agent-tests/` dentro de cada exemplo, todos ignorados.
55
+ Índice e relatório ficam em `README.md` e `REPORT.json` na pasta informada.
56
+ Logs e provas ficam em `.agent-tests/` dentro de cada exemplo, todos ignorados.