@pathbit/agentdevkit 0.0.23 → 0.0.25

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.
@@ -24,6 +24,12 @@ urgente, defina critérios próprios AC-001 e registre o motivo.
24
24
  revisão e `revisar-entrega check <relatorio>` pelo mesmo seletor. Falha,
25
25
  critério sem prova ou revisão pendente bloqueiam o commit/merge.
26
26
 
27
+ Antes de um merge fast-forward, na origem, execute `revisar-entrega check-merge
28
+ --destino refs/heads/<destino> <relatorio> [outros-relatorios...]`. A verificação
29
+ exige cobertura do diff e correspondência com o commit revisado. Ela bloqueia
30
+ históricos divergentes, links, submódulos, arquivos executáveis e operações Git
31
+ acima de 64 MiB. Confirme os hashes retornados antes de integrar.
32
+
27
33
  Saída: resultado por critério, pendências e caminho do único `revisao.json` em
28
34
  `.agent-tests/<referencia>/<execucao>/`. Esta etapa é obrigatória após qualquer
29
35
  implementação; a ausência de spec não elimina a revisão.
@@ -26,7 +26,7 @@ function pathInRoot(root, input) {
26
26
  }
27
27
 
28
28
  function git(root, args, allowFailure = false) {
29
- const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true });
29
+ const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' } });
30
30
  if ((result.error || result.status !== 0) && !allowFailure) fail(`Git: ${result.error?.message || result.stderr?.trim() || 'operacao falhou'}`);
31
31
  return result;
32
32
  }
@@ -53,7 +53,7 @@ function tracked(root, file) {
53
53
 
54
54
  function ignoredPaths(root, files) {
55
55
  if (!files.length) return new Set();
56
- const result = spawnSync('git', ['check-ignore', '--no-index', '--stdin', '-z'], { cwd: root, input: files.join('\0') + '\0', encoding: 'utf8', timeout: 15000, windowsHide: true });
56
+ const result = spawnSync('git', ['check-ignore', '--no-index', '--stdin', '-z'], { cwd: root, input: files.join('\0') + '\0', encoding: 'utf8', timeout: 15000, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' } });
57
57
  if (result.error || ![0, 1].includes(result.status)) fail(`Git: ${result.error?.message || result.stderr?.trim() || 'falha ao conferir ignores'}`);
58
58
  return new Set(result.stdout.split('\0').filter(Boolean));
59
59
  }
@@ -211,11 +211,79 @@ function validateReview(file, { root = projectRoot, requireSeal = true } = {}) {
211
211
  const recorded = JSON.parse(readFileSync(join(root, sealPath), 'utf8'));
212
212
  if (JSON.stringify(recorded) !== JSON.stringify(seal)) fail('Relatorio, baseline ou evidencia alterados apos selar a revisao.');
213
213
  }
214
- return { root, prefix, seal, result: { status: 'passed', report: reportPath, criteria: ids.size, documents: docs.size } };
214
+ return { root, prefix, seal, snapshot: state, result: { status: 'passed', report: reportPath, criteria: ids.size, documents: docs.size } };
215
215
  }
216
216
 
217
217
  export function checkReview(file, { root = projectRoot } = {}) { return validateReview(file, { root, requireSeal: true }).result; }
218
218
 
219
+ // This deliberately permits only a fast-forward and regular non-executable
220
+ // files. Existing review snapshots do not record modes or merge results.
221
+ export function checkMerge(reports, destination, { root = projectRoot } = {}) {
222
+ requireNode(); root = repository(root);
223
+ if (!Array.isArray(reports) || !reports.length) fail('Informe um ou mais relatorios selados, sem repeticao.');
224
+ reports = reports.map(file => pathInRoot(root, file));
225
+ if (new Set(reports).size !== reports.length) fail('Informe relatorios selados sem repeticao.');
226
+ if (typeof destination !== 'string' || !destination.startsWith('refs/heads/') || git(root, ['check-ref-format', destination], true).status !== 0) fail('Destino deve ser uma branch local completa: refs/heads/<nome>.');
227
+ const currentBranch = git(root, ['symbolic-ref', '--quiet', 'HEAD'], true);
228
+ if (currentBranch.status !== 0) fail('Confira a branch de origem; HEAD destacado nao e aceito.');
229
+ const sourceRef = currentBranch.stdout.trim();
230
+ if (!sourceRef.startsWith('refs/heads/') || git(root, ['check-ref-format', sourceRef], true).status !== 0) fail('A origem deve ser uma branch local valida.');
231
+ if (sourceRef === destination) fail('Origem e destino devem ser branches distintas.');
232
+ for (const ref of [sourceRef, destination]) if (git(root, ['symbolic-ref', '--quiet', ref], true).status === 0) fail(`Branch simbolica nao e aceita: ${ref}`);
233
+ const resolveCommit = ref => {
234
+ const out = git(root, ['rev-parse', '--verify', `${ref}^{commit}`], true);
235
+ const value = out.stdout?.trim();
236
+ if (out.status !== 0 || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value || '')) fail(`Branch ou commit invalido: ${ref}`);
237
+ return value;
238
+ };
239
+ const source = resolveCommit(sourceRef), target = resolveCommit(destination);
240
+ if (head(root) !== source) fail('HEAD diverge da branch de origem.');
241
+ if (git(root, ['merge-base', '--is-ancestor', target, source], true).status !== 0) fail('Apenas fast-forward comprovado e suportado; revise o resultado integrado para historico divergente.');
242
+ const bytes = args => {
243
+ const result = spawnSync('git', args, { cwd: root, timeout: 15000, windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' } });
244
+ if (result.error || result.status !== 0) fail(`Git: ${result.error?.message || result.stderr?.toString().trim() || 'operacao falhou'}`);
245
+ return result.stdout;
246
+ };
247
+ const decode = data => { try { return new TextDecoder('utf-8', { fatal: true }).decode(data); } catch { fail('Caminho Git nao e UTF-8 valido.'); } };
248
+ const raw = bytes(['diff', '--raw', '-z', '--no-abbrev', '--no-renames', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none', target, source, '--']);
249
+ const parts = decode(raw).split('\0');
250
+ if (parts.pop() !== '' || parts.length % 2) fail('Diff Git incompleto ou ambiguo.');
251
+ const changes = new Map();
252
+ for (let i = 0; i < parts.length; i += 2) {
253
+ const match = /^:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) ([AMD])$/.exec(parts[i]);
254
+ if (!match) fail('Tipo de alteracao Git nao suportado.');
255
+ const [, beforeMode, afterMode, , , action] = match, path = pathInRoot(root, parts[i + 1]);
256
+ if (changes.has(path) || !['000000', '100644'].includes(beforeMode) || !['000000', '100644'].includes(afterMode)) fail(`Tipo ou modo nao comprovado: ${path}`);
257
+ changes.set(path, action);
258
+ }
259
+ if (!changes.size) fail('Nao ha alteracoes para integrar.');
260
+ const reviewed = new Set();
261
+ const verified = reports.map(file => validateReview(file, { root, requireSeal: true }));
262
+ for (const review of verified) {
263
+ if (review.snapshot.head !== source) fail('Relatorio nao corresponde ao commit de origem.');
264
+ for (const item of review.snapshot.files) {
265
+ const path = pathInRoot(root, item.path);
266
+ if (item.indexAction !== undefined) fail(`Acao de indice nao pode comprovar merge: ${path}`);
267
+ const tree = decode(bytes(['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', source, '--', path]));
268
+ if (!tree) {
269
+ if (item.sha256 !== null) fail(`Conteudo revisado nao esta no commit de origem: ${path}`);
270
+ } else {
271
+ const m = /^(\d{6}) blob ([a-f0-9]+)\t([^\0]+)\0$/.exec(tree);
272
+ if (!m || m[1] !== '100644' || m[3] !== path) fail(`Arquivo, link ou modo nao comprovado no commit: ${path}`);
273
+ if (item.sha256 !== createHash('sha256').update(bytes(['cat-file', 'blob', m[2]])).digest('hex')) fail(`Conteudo revisado difere do commit de origem: ${path}`);
274
+ }
275
+ if (changes.has(path)) reviewed.add(path);
276
+ }
277
+ }
278
+ const missing = [...changes.keys()].filter(path => !reviewed.has(path));
279
+ if (missing.length) fail(`Alteracoes sem revisao selada: ${missing.join(', ')}`);
280
+ // Detect moved refs or changed worktree/reports during the validation.
281
+ if (git(root, ['symbolic-ref', '--quiet', 'HEAD']).stdout.trim() !== sourceRef || resolveCommit(sourceRef) !== source || resolveCommit(destination) !== target || head(root) !== source) fail('Origem ou destino mudou durante a verificacao.');
282
+ for (const file of reports) validateReview(file, { root, requireSeal: true });
283
+ if (git(root, ['symbolic-ref', '--quiet', 'HEAD']).stdout.trim() !== sourceRef || resolveCommit(sourceRef) !== source || resolveCommit(destination) !== target || head(root) !== source) fail('Origem ou destino mudou durante a verificacao.');
284
+ return { status: 'passed', sourceRef, source, destination, target, changes: changes.size, reports: reports.length, mode: 'fast-forward' };
285
+ }
286
+
219
287
  export function sealReview(file, options) {
220
288
  const checked = validateReview(file, { ...options, requireSeal: false });
221
289
  const path = artifact(checked.root, `${checked.prefix}/seal.json`, checked.prefix, false);
@@ -224,12 +292,13 @@ export function sealReview(file, options) {
224
292
  return checkReview(file, options);
225
293
  }
226
294
 
227
- const usage = 'Uso: revisar-entrega prepare --id <slug> [--execution <slug>] [--spec <arquivo>] [--scope <arquivo>] [--document <arquivo>] [--output <artefato>]; seal|check <revisao.json>';
295
+ const usage = 'Uso: revisar-entrega prepare --id <slug> [--execution <slug>] [--spec <arquivo>] [--scope <arquivo>] [--document <arquivo>] [--output <artefato>]; seal|check <revisao.json>; check-merge --destino refs/heads/<branch> <revisao.json> [...]';
228
296
  export function main(args = process.argv.slice(2)) {
229
297
  requireNode(); const [mode, ...rest] = args;
230
298
  if (!mode || ['--help', '-h'].includes(mode)) { console.log(usage); return 0; }
231
299
  if (mode === 'check' && rest.length === 1) { console.log(JSON.stringify(checkReview(rest[0]))); return 0; }
232
300
  if (mode === 'seal' && rest.length === 1) { console.log(JSON.stringify(sealReview(rest[0]))); return 0; }
301
+ if (mode === 'check-merge' && rest.length >= 3 && rest[0] === '--destino') { console.log(JSON.stringify(checkMerge(rest.slice(2), rest[1]))); return 0; }
233
302
  if (mode !== 'prepare') fail(usage);
234
303
  const options = { scope: [], documents: [] };
235
304
  const fields = { '--id': 'id', '--execution': 'execution', '--spec': 'spec', '--output': 'output' };
@@ -1,9 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
- import { checkReview, prepareReview, sealReview, specCriteria } from '../SCRIPT.mjs';
6
+ import { checkMerge, checkReview, prepareReview, sealReview, specCriteria } from '../SCRIPT.mjs';
7
7
  import { projectRoot } from '../../lib/runtime.mjs';
8
8
 
9
9
  function fixture(t) {
@@ -247,3 +247,113 @@ test('documentos rastreados permanecem obrigatórios mesmo com regra de ignore',
247
247
  assert.ok(f.report.documentation.some(item => item.path === 'spec.md'));
248
248
  assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
249
249
  });
250
+
251
+ function mergeFixture(t, { scope = ['src/main.mjs', 'src/old.txt', 'src/new.txt'], dirty = false, seal = true, ignoredSubmodule = false } = {}) {
252
+ const f = fixture(t);
253
+ const commit = message => f.git('-c', 'user.name=Test User', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', message);
254
+ f.put('src/old.txt', 'original\n'); f.git('add', '.'); commit('test: base');
255
+ f.git('branch', '-M', 'main'); f.git('checkout', '-q', '-b', 'feature');
256
+ f.put('src/main.mjs', 'export const value=2;\n');
257
+ f.git('mv', 'src/old.txt', 'src/new.txt'); f.git('add', 'src/main.mjs'); commit('test: source');
258
+ if (ignoredSubmodule) {
259
+ const object = f.git('rev-parse', 'HEAD').trim();
260
+ f.git('update-index', '--add', '--cacheinfo', `160000,${object},vendor`);
261
+ commit('test: submodule');
262
+ f.git('config', 'diff.ignoreSubmodules', 'all');
263
+ }
264
+ if (dirty) f.put('src/main.mjs', 'export const value=3; // not committed\n');
265
+ const made = prepareReview({ root: f.root, id: 'merge', execution: 'review', scope });
266
+ for (const item of made.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
267
+ const proof = '.agent-tests/merge/review/proof.txt'; f.put(proof, 'Prova sintetica para testar validacao.\n');
268
+ for (const item of made.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'fixture local', assertion: 'snapshot comparado', evidence: [proof], status: 'passed' });
269
+ made.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
270
+ f.put(made.file, JSON.stringify(made.report));
271
+ if (seal) sealReview(made.file, { root: f.root });
272
+ return { ...f, ...made, commit };
273
+ }
274
+
275
+ test('check-merge prova fast-forward completo sem alterar indice ou arquivos humanos', t => {
276
+ const f = mergeFixture(t);
277
+ f.put('human.txt', 'staged humano\n'); f.git('add', 'human.txt'); f.put('human.txt', 'alteracao humana\n');
278
+ const index = readFileSync(join(f.root, '.git/index')), human = readFileSync(join(f.root, 'human.txt'));
279
+ const result = checkMerge([f.file], 'refs/heads/main', { root: f.root });
280
+ assert.equal(result.status, 'passed'); assert.equal(result.mode, 'fast-forward'); assert.equal(result.changes, 3);
281
+ assert.equal(result.source, f.git('rev-parse', 'HEAD').trim()); assert.equal(result.target, f.git('rev-parse', 'main').trim());
282
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index); assert.deepEqual(readFileSync(join(f.root, 'human.txt')), human);
283
+ });
284
+
285
+ test('check-merge funciona na CLI e rejeita relatorio duplicado por alias', t => {
286
+ const f = mergeFixture(t);
287
+ const script = '.pbai4devs/scripts/revisar-entrega/SCRIPT.mjs', runtime = '.pbai4devs/scripts/lib/runtime.mjs';
288
+ for (const file of [script, runtime]) { mkdirSync(dirname(join(f.root, file)), { recursive: true }); cpSync(join(projectRoot, file), join(f.root, file)); }
289
+ const args = [join(f.root, script), 'check-merge', '--destino', 'refs/heads/main', f.file];
290
+ const run = spawnSync(process.execPath, args, { cwd: f.root, encoding: 'utf8' });
291
+ assert.equal(run.status, 0, run.stderr); assert.equal(JSON.parse(run.stdout).status, 'passed');
292
+ assert.throws(() => checkMerge([f.file, `./${f.file}`], 'refs/heads/main', { root: f.root }), /sem repeticao/);
293
+ });
294
+
295
+ test('check-merge bloqueia conteudo nao commitado e escopo parcial', async t => {
296
+ await t.test('working tree diferente do commit', t => {
297
+ const f = mergeFixture(t, { dirty: true });
298
+ assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
299
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /difere do commit/);
300
+ });
301
+ await t.test('omissao de arquivo removido', t => {
302
+ const f = mergeFixture(t, { scope: ['src/main.mjs', 'src/new.txt'] });
303
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /sem revisao selada: src\/old.txt/);
304
+ });
305
+ await t.test('relatorio sem selo', t => {
306
+ const f = mergeFixture(t, { seal: false });
307
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /nao selada/);
308
+ });
309
+ });
310
+
311
+ test('check-merge aceita cobertura distribuida por relatorios selados', t => {
312
+ const f = mergeFixture(t, { scope: ['src/main.mjs', 'src/new.txt'] });
313
+ const made = prepareReview({ root: f.root, id: 'merge-extra', execution: 'review', scope: ['src/old.txt'] });
314
+ for (const item of made.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
315
+ const proof = '.agent-tests/merge-extra/review/proof.txt'; f.put(proof, 'Prova sintetica complementar.\n');
316
+ for (const item of made.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'fixture local', assertion: 'delecao revisada', evidence: [proof], status: 'passed' });
317
+ made.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
318
+ f.put(made.file, JSON.stringify(made.report)); sealReview(made.file, { root: f.root });
319
+ const result = checkMerge([f.file, made.file], 'refs/heads/main', { root: f.root });
320
+ assert.equal(result.status, 'passed'); assert.equal(result.reports, 2); assert.equal(result.changes, 3);
321
+ });
322
+
323
+ test('check-merge recusa origem e destino invalidos, divergencia e tipos nao comprovados', async t => {
324
+ await t.test('branch invalida ou identica', t => {
325
+ const f = mergeFixture(t);
326
+ for (const name of ['main', 'refs/heads/missing', 'refs/heads/feature', '--help']) assert.throws(() => checkMerge([f.file], name, { root: f.root }));
327
+ });
328
+ await t.test('historico divergente', t => {
329
+ const f = mergeFixture(t); f.git('checkout', '-q', 'main'); f.put('different.txt', 'destino\n'); f.git('add', 'different.txt'); f.commit('test: target'); f.git('checkout', '-q', 'feature');
330
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /fast-forward/);
331
+ });
332
+ await t.test('HEAD destacado', t => {
333
+ const f = mergeFixture(t); f.git('checkout', '-q', '--detach');
334
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /HEAD destacado/);
335
+ });
336
+ await t.test('origem fora das branches locais', t => {
337
+ const f = mergeFixture(t); f.git('symbolic-ref', 'HEAD', 'refs/remotes/origin/feature');
338
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /origem deve ser uma branch local/);
339
+ });
340
+ await t.test('submodulo ignorado pela configuracao Git', t => {
341
+ const f = mergeFixture(t, { ignoredSubmodule: true });
342
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /modo nao comprovado: vendor/);
343
+ const script = '.pbai4devs/scripts/revisar-entrega/SCRIPT.mjs', runtime = '.pbai4devs/scripts/lib/runtime.mjs';
344
+ for (const file of [script, runtime]) { mkdirSync(dirname(join(f.root, file)), { recursive: true }); cpSync(join(projectRoot, file), join(f.root, file)); }
345
+ const run = spawnSync(process.execPath, [join(f.root, script), 'check-merge', '--destino', 'refs/heads/main', f.file], { cwd: f.root, encoding: 'utf8' });
346
+ assert.equal(run.status, 1); assert.match(run.stderr, /modo nao comprovado: vendor/);
347
+ });
348
+ await t.test('link simbolico', t => {
349
+ const f = mergeFixture(t);
350
+ try { symlinkSync('main.mjs', join(f.root, 'src/link')); }
351
+ catch (error) { if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) return t.skip('Sem permissao de symlink.'); throw error; }
352
+ f.git('add', 'src/link'); f.commit('test: link');
353
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /simbolico|modo nao comprovado/);
354
+ });
355
+ await t.test('arquivo executavel', t => {
356
+ const f = mergeFixture(t); f.git('update-index', '--chmod=+x', 'src/main.mjs'); f.commit('test: mode');
357
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /modo nao comprovado/);
358
+ });
359
+ });
@@ -9,8 +9,8 @@ import { buildCatalog } from '../lib/catalog.mjs';
9
9
  import { resourcePath } from '../lib/resource-path.mjs';
10
10
  import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
11
11
  import { nativePathBoundary } from '../lib/native-paths.mjs';
12
- import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
13
- import { mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
12
+ import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, extractManagedBlock, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
13
+ import { extractTomlManagedBlock, mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
14
14
  import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, planUntrack, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
15
15
  import { stateDirectory } from '../lib/state-path.mjs';
16
16
 
@@ -90,7 +90,7 @@ function replaceJsonMember(bytes, member, value) {
90
90
  // Compare owned fields structurally, preserving all other JSON bytes.
91
91
  function derivedContent(original, previous, next) {
92
92
  if (!original) return JSON.stringify(next, null, 2) + '\n';
93
- const keys = ['schemaVersion', 'entries', 'outputs', 'files', 'blocks', 'removedBlocks', 'removedFiles'];
93
+ const keys = ['schemaVersion', 'entries', 'outputs', 'files', 'blocks', 'removedBlocks', 'removedFiles', 'catalogs', 'config'];
94
94
  const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
95
95
  ? Object.fromEntries(Object.keys(value).sort(compare).map(key => [key, canonical(value[key])]))
96
96
  : Array.isArray(value) ? value.map(canonical) : value;
@@ -247,6 +247,11 @@ export function synchronize({ root = projectRoot, check = false, force = false,
247
247
  if (previousEntries?.schemaVersion !== 1 || !previousEntries.entries || typeof previousEntries.entries !== 'object' || Array.isArray(previousEntries.entries) || Object.entries(previousEntries.entries).some(([name, hash]) => !canonicalEntrypoint(name) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de entradas derivadas invalido.');
248
248
  const previousOutputs = previousEntries.outputs ?? {};
249
249
  if (!previousOutputs || typeof previousOutputs !== 'object' || Array.isArray(previousOutputs) || Object.entries(previousOutputs).some(([path, hash]) => !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/(skills|rules|agents)\.json$/.test(path) || reserved.has(path.split('/')[0].toLowerCase()) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de saidas derivadas invalido.');
250
+ const previousCatalogs = previousEntries.catalogs ?? {};
251
+ const catalogPaths = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
252
+ if (!previousCatalogs || typeof previousCatalogs !== 'object' || Array.isArray(previousCatalogs) || Object.entries(previousCatalogs).some(([path, hash]) => !catalogPaths.has(path) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de catalogos derivados invalido.');
253
+ for (const path of Object.keys(previousCatalogs)) safe(path);
254
+ if (previousEntries.config !== undefined && (typeof previousEntries.config !== 'string' || !/^[a-f0-9]{64}$/.test(previousEntries.config))) fail('Registro de configuracao derivada invalido.');
250
255
  const validResourcePath = path => {
251
256
  if (typeof path !== 'string' || !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/.+/.test(path)) return false;
252
257
  const [root, scope] = path.split('/');
@@ -254,7 +259,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
254
259
  && !['readme.md', 'instructions.md'].includes(posix.basename(path).toLowerCase())
255
260
  && (/\.(md|mdc)$/i.test(path) || path.startsWith('.gemini/commands/') && path.endsWith('.toml'));
256
261
  };
257
- const validIgnoreBlock = path => typeof path === 'string' && /^\.[A-Za-z0-9][A-Za-z0-9._-]*\/\.gitignore$/.test(path) && !reserved.has(path.split('/')[0].toLowerCase());
262
+ const validIgnoreBlock = path => path === '.pbai4devs/.gitignore' || typeof path === 'string' && /^\.[A-Za-z0-9][A-Za-z0-9._-]*\/\.gitignore$/.test(path) && !reserved.has(path.split('/')[0].toLowerCase());
258
263
  for (const [name, values] of [['files', previousEntries.files ?? {}], ['blocks', previousEntries.blocks ?? {}]]) {
259
264
  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.`);
260
265
  for (const path of Object.keys(values)) safe(canonicalEntrypoint(path) ? resolveEntrypoint(root, canonicalEntrypoint(path)) : path);
@@ -475,11 +480,23 @@ export function synchronize({ root = projectRoot, check = false, force = false,
475
480
  }
476
481
  const registries = new Map();
477
482
  const discoveryOutputs = {};
483
+ const catalogOutputs = {};
478
484
  for (const [target, content] of built.outputs) {
479
485
  const info = stat(safe(target));
480
486
  if (info && !info.isFile()) fail(`Registro nao e arquivo: ${target}`);
481
487
  const current = info ? readFileSync(absolute(target)) : null;
482
488
  captureInput(target, current);
489
+ const currentHash = current && createHash('sha256').update(current).digest('hex');
490
+ const known = current && [installed?.files?.[target], previousCatalogs[target]].some(digest => digest && digest === currentHash);
491
+ if (current && !known) {
492
+ // Identical bytes do not establish ownership. An unowned catalogue can
493
+ // remain if already current, but it cannot be adopted or regenerated.
494
+ remember(target, 'existing-catalog');
495
+ if (current.toString('utf8') !== content) { conflicts.add(target); protectedConflicts.add(target); }
496
+ continue;
497
+ }
498
+ const nextHash = createHash('sha256').update(content).digest('hex');
499
+ if (installed?.files?.[target] !== nextHash) catalogOutputs[target] = nextHash;
483
500
  if (!current || current.toString('utf8') !== content) {
484
501
  registries.set(target, content);
485
502
  if (force && info) backup.add(target);
@@ -533,10 +550,22 @@ export function synchronize({ root = projectRoot, check = false, force = false,
533
550
  merged = mergeGitignore(current, patterns);
534
551
  }
535
552
  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');
553
+ if (merged.hasBlock) {
554
+ const start = current.indexOf(ignoreBegin), finish = current.indexOf(ignoreEnd);
555
+ const blockHash = start >= 0 && finish > start ? createHash('sha256').update(current.slice(start, finish + ignoreEnd.length)).digest('hex') : null;
556
+ const fileHash = createHash('sha256').update(current).digest('hex');
557
+ const known = [installed?.blocks?.[target], previousEntries.blocks?.[target]].some(hash => hash && hash === blockHash)
558
+ || priorFiles[target] && priorFiles[target] === fileHash;
559
+ if (!known) {
560
+ // A marker or identical contents is not proof that this block belongs
561
+ // to the kit. Do not adopt, replace or remove it even with --force.
562
+ remember(target, 'existing-ignore-block');
563
+ if (merged.text !== current) { conflicts.add(target); protectedConflicts.add(target); }
564
+ continue;
565
+ }
539
566
  }
567
+ const start = merged.text.indexOf(ignoreBegin), finish = merged.text.indexOf(ignoreEnd);
568
+ if (start >= 0 && finish > start) managedBlocks[target] = createHash('sha256').update(merged.text.slice(start, finish + ignoreEnd.length)).digest('hex');
540
569
  if (merged.text !== current) {
541
570
  ignoreFiles.set(target, merged.text);
542
571
  if (force && info && merged.hasBlock) backup.add(target);
@@ -568,6 +597,10 @@ export function synchronize({ root = projectRoot, check = false, force = false,
568
597
  if (!command) fail(`Comando Gemini exige COMMAND.md: ${source}`);
569
598
  const current = mixed ? targetBytes : null;
570
599
  const merged = mixed ? mergeTomlContent(current, `@{${source}}`, { expectedHash: priorBlocks[target] }) : null;
600
+ if (current && !priorBlocks[target] && priorFiles[target] !== createHash('sha256').update(current).digest('hex') && extractTomlManagedBlock(current)) {
601
+ // An identical block can remain, but its bytes are not ownership proof.
602
+ remember(target, 'existing-managed-block'); expected.set(target, current); continue;
603
+ }
571
604
  const content = merged ? merged.text : `${tomlMarker}\ndescription = ${JSON.stringify(command.description)}\nprompt = ${JSON.stringify(`@{${source}}\n\n{{args}}`)}\n`;
572
605
  if (mixed) { captureInput(target, current); managedBlocks[target] = merged.sha256; remember(target, 'existing-file-extended'); }
573
606
  else rememberGenerated(target, content);
@@ -588,6 +621,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
588
621
  const inclusion = ruleInclusion ? `@[origem](${link(target, source)})` : `Leia e siga [origem](${link(target, source)}).`;
589
622
  const current = mixed ? targetBytes : null;
590
623
  const merged = mixed ? mergeManagedContent(current, inclusion, { expectedHash: priorBlocks[target] }) : null;
624
+ if (current && !priorBlocks[target] && priorFiles[target] !== createHash('sha256').update(current).digest('hex') && extractManagedBlock(current)) {
625
+ remember(target, 'existing-managed-block'); expected.set(target, current); continue;
626
+ }
591
627
  const content = merged ? merged.text : `${frontmatter}\n${title}\n\n${marker}\n${inclusion}\n`;
592
628
  if (mixed) { captureInput(target, current); managedBlocks[target] = merged.sha256; remember(target, 'existing-file-extended'); }
593
629
  else rememberGenerated(target, content);
@@ -644,6 +680,9 @@ export function synchronize({ root = projectRoot, check = false, force = false,
644
680
  const previousBlockHash = priorBlocks[name] ?? Object.entries(priorBlocks).find(([path]) => canonicalEntrypoint(path) === canonical)?.[1];
645
681
  if (current && (previousBlockHash || createHash('sha256').update(current).digest('hex') !== previousHash)) {
646
682
  const merged = mergeManagedContent(current, entrypointReference(root, canonical), { expectedHash: previousBlockHash });
683
+ if (!previousBlockHash && previousHash !== createHash('sha256').update(current).digest('hex') && extractManagedBlock(current)) {
684
+ remember(name, 'existing-managed-block'); continue;
685
+ }
647
686
  captureInput(name, current);
648
687
  managedBlocks[name] = merged.sha256;
649
688
  rootEntries.set(name, merged.text);
@@ -682,8 +721,20 @@ export function synchronize({ root = projectRoot, check = false, force = false,
682
721
  removedBlocks.add(target);
683
722
  remember(target, 'existing-file-extended');
684
723
  }
724
+ const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
725
+ const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
726
+ const configChanged = !configExists || configText !== finalConfig;
727
+ const configHash = configExists ? createHash('sha256').update(readFileSync(absolute(config))).digest('hex') : null;
728
+ const configKnown = configExists && [installed?.files?.[config], previousEntries.config].some(hash => hash && hash === configHash);
729
+ if (configExists && !configKnown) {
730
+ remember(config, 'existing-configuration');
731
+ if (configChanged) { conflicts.add(config); protectedConflicts.add(config); }
732
+ }
685
733
  const next = { schemaVersion: 1, entries: nextEntries };
686
734
  if (Object.keys(discoveryOutputs).length) next.outputs = discoveryOutputs;
735
+ if (Object.keys(catalogOutputs).length) next.catalogs = catalogOutputs;
736
+ const nextConfigHash = createHash('sha256').update(finalConfig).digest('hex');
737
+ if ((!configExists || configKnown) && installed?.files?.[config] !== nextConfigHash) next.config = nextConfigHash;
687
738
  if (Object.keys(generatedFiles).length) next.files = generatedFiles;
688
739
  const derivedBlocks = Object.fromEntries(Object.entries(managedBlocks).filter(([path, hash]) => installed?.blocks?.[path] !== hash || Object.hasOwn(previousEntries.blocks ?? {}, path)));
689
740
  if (Object.keys(derivedBlocks).length) next.blocks = derivedBlocks;
@@ -693,7 +744,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
693
744
  for (const path of remove) if (validResourcePath(path) && installed?.files?.[path] && !expected.has(path)) effectiveRemovedFiles[path] = installed.files[path];
694
745
  if (Object.keys(effectiveRemovedFiles).length) next.removedFiles = Object.fromEntries(Object.entries(effectiveRemovedFiles).sort(([a], [b]) => compare(a, b)));
695
746
  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);
747
+ if (recordDerivedState && (Object.keys(nextEntries).length || Object.keys(discoveryOutputs).length || Object.keys(catalogOutputs).length || next.config || 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);
697
748
  const ownedOutputs = sorted([...targets, ...built.outputs.keys(), '.pbai4devs/espelhos.conf', ...rootEntries.keys(), ...(recordDerivedState && (stat(safe(entryRegistry)) || registries.has(entryRegistry)) ? [entryRegistry] : []),
698
749
  ...[...discoveries].flatMap(agent => ['skills', 'rules', 'agents'].map(kind => `${agent}/${kind}.json`)),
699
750
  ...[...ignoreTemplates.keys()].filter(target => {
@@ -709,9 +760,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
709
760
  const removals = topPaths(remove);
710
761
  const backups = topPaths(backup);
711
762
  const rootEntryChanges = [...rootEntries].filter(([name, content]) => !stat(absolute(name)) || readFileSync(absolute(name), 'utf8') !== content);
712
- const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
713
- const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
714
- const configChanged = !configExists || configText !== finalConfig;
715
763
  // Every planned write/removal may cover a different staged version. Compare
716
764
  // the worktree bytes with the index before touching any path, even in force
717
765
  // mode, and repeat immediately before applying the transaction.