@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.
- package/.pbai4devs/COMPATIBILIDADE.md +11 -1
- package/.pbai4devs/scripts/diagnosticar-ambiente/tests/TEST.mjs +60 -1
- package/.pbai4devs/scripts/lib/adapters.mjs +5 -2
- package/.pbai4devs/scripts/lib/runtime.mjs +38 -2
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +5 -1
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +136 -9
- package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +30 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +22 -0
- package/.pbai4devs/scripts/testar-maturidade/README.md +12 -10
- package/.pbai4devs/scripts/testar-maturidade/SCRIPT.mjs +86 -44
- package/.pbai4devs/scripts/testar-maturidade/tests/TEST.mjs +157 -33
- package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +12 -1
- package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +22 -0
- package/.pbai4devs/templates/clients/ADAPTERS.json +3 -2
- package/CHANGELOG.md +15 -0
- package/docs/maintainers/maintenance.md +16 -5
- package/docs/specs/README.md +3 -0
- package/docs/specs/automatic-release-tags/SPEC.md +16 -0
- package/docs/specs/discovery-preservation/SPEC.md +26 -0
- package/package.json +1 -1
- package/src/install.mjs +134 -12
|
@@ -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`.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
572
|
-
|
|
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.
|
|
18
|
-
`examples/generated
|
|
19
|
-
|
|
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/`.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
54
|
-
|
|
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.
|