@pathbit/agentdevkit 0.0.17 → 0.0.19
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/commands/sincronizar/COMMAND.md +6 -4
- package/.pbai4devs/scripts/lib/gitignore.mjs +100 -43
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +12 -7
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +70 -31
- package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +155 -17
- package/.pbai4devs/scripts/sincronizar-agentes/tests/PRESERVATION.mjs +306 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +11 -3
- package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +23 -6
- package/.pbai4devs/templates/gitignore/TEMPLATE.md +14 -7
- package/.pbai4devs/templates/gitignore/shared.ignore +1 -0
- package/CHANGELOG.md +11 -0
- package/README.en.md +1 -1
- package/README.es.md +2 -2
- package/docs/guides/cli.md +48 -15
- package/docs/guides/installation.md +49 -23
- package/docs/guides/troubleshooting.md +3 -2
- package/docs/product/design.md +6 -6
- package/docs/specs/README.md +2 -0
- package/docs/specs/client-ignore/SPEC.md +21 -0
- package/docs/specs/safe-synchronization/SPEC.md +21 -0
- package/package.json +1 -1
- package/src/i18n.mjs +10 -0
- package/src/install.mjs +227 -28
|
@@ -4,14 +4,16 @@ description: Atualiza ou confere os apontamentos compartilhados dos agentes.
|
|
|
4
4
|
|
|
5
5
|
# Sincronizar
|
|
6
6
|
|
|
7
|
-
Entrada:
|
|
7
|
+
Entrada: este comando agêntico usa o script local com `--force` para reparar;
|
|
8
|
+
`--check` só confere. O `sync` da CLI usa as fontes locais e não implica
|
|
9
|
+
`--force`; `update` usa as fontes da versão do pacote executado.
|
|
8
10
|
Pré-requisitos: Node.js 24+ e manifesto `.pbai4devs/espelhos.conf` válido.
|
|
9
11
|
|
|
10
12
|
Siga [sincronizar-agentes](../../skills/projeto/sincronizar-agentes/SKILL.md)
|
|
11
13
|
e execute o script correspondente pelo seletor. A reconstrução repara
|
|
12
|
-
apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada
|
|
13
|
-
|
|
14
|
-
inclusive
|
|
14
|
+
apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada.
|
|
15
|
+
Recursos adicionais, configurações, pastas e links humanos são preservados,
|
|
16
|
+
inclusive em colisões de nomes. O backup local não substitui o histórico Git.
|
|
15
17
|
Após reparar, execute também `--check`.
|
|
16
18
|
|
|
17
19
|
Respeite os clientes registrados no manifesto. Para mudar a seleção salva,
|
|
@@ -98,10 +98,33 @@ function localBlock(current, first, last, body) {
|
|
|
98
98
|
return text.slice(0, start) + block + text.slice(finish);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function gitCommand(root, args, input) {
|
|
101
|
+
function gitCommand(root, args, input, encoding = 'utf8') {
|
|
102
102
|
const env = { ...process.env };
|
|
103
103
|
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[key];
|
|
104
|
-
return spawnSync('git', [...(['ls-files', 'rm'].includes(args[0]) ? ['--literal-pathspecs'] : []), '-C', root, ...args], { input, encoding
|
|
104
|
+
return spawnSync('git', [...(['ls-files', 'rm'].includes(args[0]) ? ['--literal-pathspecs'] : []), '-C', root, ...args], { input, encoding, env, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Compare exact bytes first. Only accept Git's plain text CRLF conversion when
|
|
108
|
+
// effective attributes prove it applies and no custom transformation can run.
|
|
109
|
+
// This deliberately does not invoke hash-object or repository-defined filters.
|
|
110
|
+
export function indexContentMatches(root, path, working, indexed) {
|
|
111
|
+
if (working.equals(indexed)) return true;
|
|
112
|
+
const attributes = gitCommand(root, ['check-attr', '-z', 'text', 'eol', 'filter', 'working-tree-encoding', 'ident', '--', path]);
|
|
113
|
+
if (attributes.status !== 0) return false;
|
|
114
|
+
const parts = attributes.stdout.split('\0');
|
|
115
|
+
const values = {};
|
|
116
|
+
for (let i = 0; i + 2 < parts.length; i += 3) values[parts[i + 1]] = parts[i + 2];
|
|
117
|
+
const absent = value => value === 'unspecified' || value === 'unset';
|
|
118
|
+
if (!absent(values.filter) || !absent(values['working-tree-encoding']) || !absent(values.ident) || values.text === 'unset') return false;
|
|
119
|
+
const config = gitCommand(root, ['config', '--bool', 'core.autocrlf']);
|
|
120
|
+
if (config.status !== 0 && config.status !== 1) return false;
|
|
121
|
+
const textEnabled = values.text === 'set' || values.text === 'auto' || ['lf', 'crlf'].includes(values.eol) || config.status === 0 && config.stdout.trim() === 'true';
|
|
122
|
+
if (!textEnabled || !working.includes(13) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(working.toString('latin1'))) return false;
|
|
123
|
+
try {
|
|
124
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(working);
|
|
125
|
+
if (text.replaceAll('\r\n', '').includes('\r')) return false;
|
|
126
|
+
return Buffer.from(text.replaceAll('\r\n', '\n')).equals(indexed);
|
|
127
|
+
} catch { return false; }
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
function checkedOutput(path) {
|
|
@@ -309,26 +332,6 @@ function ambiguousExceptions(root, location, clientRoots, artifacts) {
|
|
|
309
332
|
return conflicts;
|
|
310
333
|
}
|
|
311
334
|
|
|
312
|
-
function foreignHiddenClients(plan) {
|
|
313
|
-
if (!plan.location) return [];
|
|
314
|
-
const content = localRead(plan.location.exclude) || '', ranges = [];
|
|
315
|
-
let start = null;
|
|
316
|
-
content.split(/\r?\n/).forEach((line, index) => {
|
|
317
|
-
const begin = line.match(/^# BEGIN AGENTDEVKIT LOCAL ([a-f0-9]{16})$/);
|
|
318
|
-
const end = line.match(/^# END AGENTDEVKIT LOCAL ([a-f0-9]{16})$/);
|
|
319
|
-
if (begin) start = { id: begin[1], line: index + 1 };
|
|
320
|
-
else if (end && start?.id === end[1]) { ranges.push({ ...start, end: index + 1 }); start = null; }
|
|
321
|
-
});
|
|
322
|
-
const probes = plan.clientRoots.filter(path => { const item = localStat(join(plan.root, path)); return !item || item.isDirectory() && !item.isSymbolicLink(); }).map(path => `${path}/.agentdevkit-human-probe.md`);
|
|
323
|
-
if (!probes.length) return [];
|
|
324
|
-
const { paths, matches } = ignoreMatches(plan.location, probes);
|
|
325
|
-
return paths.flatMap((path, index) => {
|
|
326
|
-
const match = matches.get(path);
|
|
327
|
-
if (!match || match.pattern.startsWith('!') || resolve(plan.location.top, match.source) !== plan.location.exclude) return [];
|
|
328
|
-
return ranges.some(range => `# BEGIN AGENTDEVKIT LOCAL ${range.id}` !== plan.first && match.line >= range.line && match.line <= range.end) ? [probes[index]] : [];
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
335
|
function localIgnoreSource(location, source) {
|
|
333
336
|
const path = resolve(location.top, source);
|
|
334
337
|
if (path === location.exclude) return true;
|
|
@@ -347,6 +350,21 @@ function ignoreMatches(location, probes) {
|
|
|
347
350
|
return { paths, matches };
|
|
348
351
|
}
|
|
349
352
|
|
|
353
|
+
// A nested .gitignore cannot reopen a project hidden by an ignored ancestor.
|
|
354
|
+
// Detect this before claiming that its client exceptions are effective.
|
|
355
|
+
function ignoredProjectAncestors(location) {
|
|
356
|
+
if (!location.prefix) return [];
|
|
357
|
+
const segments = location.prefix.split('/');
|
|
358
|
+
const paths = segments.map((_part, index) => segments.slice(0, index + 1).join('/'));
|
|
359
|
+
const result = gitCommand(location.top, ['check-ignore', '--verbose', '--no-index', '--stdin', '-z'], `${paths.join('\0')}\0`);
|
|
360
|
+
if (result.error || ![0, 1].includes(result.status)) throw new Error(result.error?.message || result.stderr || 'Falha ao conferir ancestrais Git.');
|
|
361
|
+
const values = result.stdout.split('\0'), ignored = [];
|
|
362
|
+
for (let index = 0; index + 3 < values.length; index += 4) {
|
|
363
|
+
if (!values[index + 2].startsWith('!')) ignored.push(`Ancestral Git ignorado: ${values[index + 3]}/; reabra-o no .gitignore do repositorio pai`);
|
|
364
|
+
}
|
|
365
|
+
return ignored;
|
|
366
|
+
}
|
|
367
|
+
|
|
350
368
|
function localRoot(value) {
|
|
351
369
|
const missing = []; let parent = resolve(value);
|
|
352
370
|
while (!localStat(parent)) { missing.unshift(parent.slice(dirname(parent).length + 1)); parent = dirname(parent); }
|
|
@@ -364,15 +382,17 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
|
|
|
364
382
|
const artifacts = artifactPolicy(templateFile);
|
|
365
383
|
if (template.split('{{OUTPUTS}}').length !== 2) throw new Error('local.ignore exige um unico {{OUTPUTS}}.');
|
|
366
384
|
if (template.split('{{ROOTS}}').length > 2) throw new Error('local.ignore permite um unico {{ROOTS}}.');
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
const roots = [];
|
|
385
|
+
// Client roots are ignored locally. Projects can explicitly opt in to a
|
|
386
|
+
// complete client with a literal negation in their own root .gitignore.
|
|
370
387
|
const localPolicy = join(templateRoot || root, '.pbai4devs/templates/clients/ADAPTERS.json');
|
|
371
388
|
const policy = JSON.parse(readFileSync(checkedLocalPath(localStat(localPolicy) ? localPolicy : fileURLToPath(new URL('../../templates/clients/ADAPTERS.json', import.meta.url))), 'utf8'));
|
|
372
389
|
const clientRoots = [...new Set(policy.adapters.flatMap(adapter => [adapter.root, ...(adapter.extraRoots || [])]))];
|
|
373
390
|
if (clientRoots.some(path => typeof path !== 'string' || !/^\.[a-z0-9][a-z0-9-]*$/.test(path) || ['.git', '.pbai4devs', '.agent-tests'].includes(path))) throw new Error('Raiz de cliente invalida para exclusao.');
|
|
391
|
+
const roots = [...clientRoots];
|
|
374
392
|
const privateClients = privateClientPolicy(templateFile, clientRoots);
|
|
375
|
-
const
|
|
393
|
+
const outputPaths = [...new Set(outputs.map(checkedOutput))];
|
|
394
|
+
const paths = [...new Set([...outputPaths, ...template.split(/\r?\n/).filter(line => line.startsWith('/')).map(line => checkedOutput(line.slice(1)))])]
|
|
395
|
+
.filter(path => !roots.some(client => path === client || path.startsWith(`${client}/`))).sort();
|
|
376
396
|
const info = gitCommand(resolved.parent, ['rev-parse', '--show-toplevel', '--git-path', 'info/exclude']);
|
|
377
397
|
let location = null;
|
|
378
398
|
if (info.status === 0) {
|
|
@@ -389,6 +409,19 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
|
|
|
389
409
|
throw new Error(`Nao foi possivel localizar Git: ${info.error?.message || info.stderr}`);
|
|
390
410
|
}
|
|
391
411
|
const id = createHash('sha256').update(root).digest('hex').slice(0, 16);
|
|
412
|
+
const projectIgnore = join(root, '.gitignore'), projectIgnoreStat = localStat(projectIgnore);
|
|
413
|
+
const literalExceptions = projectIgnoreStat?.isFile() && !projectIgnoreStat.isSymbolicLink()
|
|
414
|
+
? new Set(readFileSync(projectIgnore, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)) : new Set();
|
|
415
|
+
const requestedRoots = location ? roots.filter(client => literalExceptions.has(`!/${client}/`) || literalExceptions.has(`!${client}/`)) : [];
|
|
416
|
+
const optCandidates = requestedRoots.filter(client => safeIgnoreProbe(root, `${client}/.agentdevkit-opt-in-probe`));
|
|
417
|
+
// A later rule or an ancestor can override a literal negation. Consult Git
|
|
418
|
+
// before accepting it, including when the client folder does not yet exist.
|
|
419
|
+
const optProbes = optCandidates.map(client => `${client}/.agentdevkit-opt-in-probe`);
|
|
420
|
+
const optMatches = location && optProbes.length ? ignoreMatches(location, optProbes) : { paths: [], matches: new Map() };
|
|
421
|
+
const optedRoots = optCandidates.filter((_client, index) => {
|
|
422
|
+
const match = optMatches.matches.get(optMatches.paths[index]);
|
|
423
|
+
return !match || match.pattern.startsWith('!');
|
|
424
|
+
});
|
|
392
425
|
const first = `# BEGIN AGENTDEVKIT LOCAL ${id}`, last = `# END AGENTDEVKIT LOCAL ${id}`;
|
|
393
426
|
const fixed = template.replace('{{ROOTS}}', roots.map(path => `/${path}/`).join('\n')).replace('{{OUTPUTS}}', paths.filter(path => !roots.some(root => path === root || path.startsWith(root + '/'))).map(path => `/${escapePattern(path)}`).join('\n'))
|
|
394
427
|
+ '\n# Estado privado dos clientes\n' + privateClients.rules.join('\n') + '\n'
|
|
@@ -401,10 +434,9 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
|
|
|
401
434
|
const target = location?.exclude;
|
|
402
435
|
const before = target ? localRead(target) : null, after = target ? localBlock(before, first, last, body) : null;
|
|
403
436
|
const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
|
|
404
|
-
const probes = [...paths, ...rootProbes(root, roots)];
|
|
437
|
+
const probes = [...paths, ...rootProbes(root, roots.filter(client => !optedRoots.includes(client)))];
|
|
405
438
|
const existingPrivate = privateClients.probes.filter(path => localStat(join(root, path.split('/')[0]))?.isDirectory() && safeIgnoreProbe(root, path));
|
|
406
439
|
const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...existingPrivate, ...(location ? [...visibleArtifactProbes(root, location, artifacts), ...visiblePrivateProbes(root, location, clientRoots)] : [])])];
|
|
407
|
-
const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
|
|
408
440
|
if (location && !before?.includes(first)) {
|
|
409
441
|
const ignored = ignoreMatches(location, [...probes, ...artifactPaths].filter(path => safeIgnoreProbe(root, path)));
|
|
410
442
|
if (ignored.paths.every(path => { const match = ignored.matches.get(path); return match && !match.pattern.startsWith('!') && localIgnoreSource(location, match.source); })) edits.length = 0;
|
|
@@ -413,14 +445,16 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
|
|
|
413
445
|
const tracked = location ? gitCommand(location.top, ['ls-files', '-z']).stdout.split('\0').filter(path => {
|
|
414
446
|
if (!path.startsWith(prefix)) return false;
|
|
415
447
|
const local = path.slice(prefix.length);
|
|
416
|
-
|
|
448
|
+
// Git ignore never untracks existing files. Report private data and test
|
|
449
|
+
// artifacts, but don't flag legitimate already-tracked project content.
|
|
450
|
+
return artifactPath(local, artifacts) || clientRoots.includes(local.split('/')[0]) && isGeneratedOutput(local);
|
|
417
451
|
}).map(path => path.slice(prefix.length)) : [];
|
|
418
|
-
return { root, status: location ? 'git' : 'no-git', location, paths, roots, clientRoots, artifactPatterns: artifacts, artifactProbes: artifactPaths, privateProbes: privateClients.probes, edits, trackedOutputs: tracked, first, last };
|
|
452
|
+
return { root, status: location ? 'git' : 'no-git', location, paths, outputPaths, roots, requestedRoots, optedRoots, clientRoots, artifactPatterns: artifacts, artifactProbes: artifactPaths, privateProbes: privateClients.probes, edits, trackedOutputs: tracked, first, last };
|
|
419
453
|
}
|
|
420
454
|
|
|
421
455
|
export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
|
|
422
456
|
const missing = [];
|
|
423
|
-
if (plan.location) missing.push(...ambiguousExceptions(plan.root, plan.location, plan.clientRoots || [], plan.artifactPatterns || [])
|
|
457
|
+
if (plan.location) missing.push(...((plan.requestedRoots || []).length ? ignoredProjectAncestors(plan.location) : []), ...ambiguousExceptions(plan.root, plan.location, plan.clientRoots || [], plan.artifactPatterns || []));
|
|
424
458
|
const replacements = replacedBlocks.map(item => {
|
|
425
459
|
const path = canonicalExisting(checkedLocalPath(resolve(item.path))), inside = relative(localRoot(plan.root).root, path);
|
|
426
460
|
if (isAbsolute(inside) || inside === '..' || inside.startsWith(`..${sep}`) || !Number.isInteger(item.firstLine) || !Number.isInteger(item.lastLine) || item.firstLine < 1 || item.lastLine < item.firstLine || !/^[a-f0-9]{64}$/.test(item.sha256)) throw new Error('Faixa de ignore gerenciado invalida.');
|
|
@@ -433,9 +467,9 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
|
|
|
433
467
|
return { ...item, path };
|
|
434
468
|
});
|
|
435
469
|
if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
|
|
436
|
-
missing.push(...plan.paths.filter(path => !safeIgnoreProbe(plan.root, path)));
|
|
470
|
+
missing.push(...new Set([...plan.paths, ...(plan.outputPaths || [])].filter(path => !safeIgnoreProbe(plan.root, path))));
|
|
437
471
|
const privateProbes = (plan.privateProbes || []).filter(path => localStat(join(plan.root, path.split('/')[0]))?.isDirectory() && safeIgnoreProbe(plan.root, path));
|
|
438
|
-
const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || []), ...privateProbes,
|
|
472
|
+
const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, (plan.roots || []).filter(client => !(plan.optedRoots || []).includes(client))), ...(plan.artifactProbes || []), ...privateProbes,
|
|
439
473
|
...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : []),
|
|
440
474
|
...visiblePrivateProbes(plan.root, plan.location, plan.clientRoots || [])])].filter(path => safeIgnoreProbe(plan.root, path));
|
|
441
475
|
const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
|
|
@@ -458,12 +492,13 @@ export function assertLocalIgnoreUnchanged(plan) {
|
|
|
458
492
|
}
|
|
459
493
|
}
|
|
460
494
|
|
|
461
|
-
function atomicLocalWrite(path, content, mode, published) {
|
|
495
|
+
function atomicLocalWrite(path, content, mode, published, beforePublish) {
|
|
462
496
|
const temporary = `${path}.agentdevkit-${randomUUID()}`;
|
|
463
497
|
try {
|
|
464
498
|
writeFileSync(temporary, content, { flag: 'wx' });
|
|
465
499
|
if (mode !== null && mode !== undefined) chmodSync(temporary, mode);
|
|
466
500
|
const writtenMode = localMode(temporary);
|
|
501
|
+
beforePublish?.();
|
|
467
502
|
renameSync(temporary, path);
|
|
468
503
|
published?.(writtenMode);
|
|
469
504
|
} finally { rmSync(temporary, { force: true }); }
|
|
@@ -477,7 +512,10 @@ export function applyLocalIgnore(plan) {
|
|
|
477
512
|
try {
|
|
478
513
|
for (const edit of plan.edits) {
|
|
479
514
|
mkdirSync(dirname(edit.path), { recursive: true });
|
|
480
|
-
atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }))
|
|
515
|
+
atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }), () => {
|
|
516
|
+
checkedLocalPath(edit.path);
|
|
517
|
+
if (localRead(edit.path) !== edit.before || localMode(edit.path) !== edit.beforeMode) throw new Error(`Exclusao alterada antes de publicar; conteudo preservado: ${edit.path}`);
|
|
518
|
+
});
|
|
481
519
|
}
|
|
482
520
|
const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
|
|
483
521
|
if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
|
|
@@ -486,7 +524,12 @@ export function applyLocalIgnore(plan) {
|
|
|
486
524
|
for (const { edit, mode } of written.reverse()) {
|
|
487
525
|
try {
|
|
488
526
|
if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) continue;
|
|
489
|
-
|
|
527
|
+
const checkPublished = () => {
|
|
528
|
+
checkedLocalPath(edit.path);
|
|
529
|
+
if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) throw new Error(`Exclusao alterada durante rollback; conteudo preservado: ${edit.path}`);
|
|
530
|
+
};
|
|
531
|
+
if (edit.before === null) { checkPublished(); rmSync(edit.path, { force: true }); }
|
|
532
|
+
else atomicLocalWrite(edit.path, edit.before, edit.beforeMode, undefined, checkPublished);
|
|
490
533
|
} catch (failure) { rollbackErrors.push(failure); }
|
|
491
534
|
}
|
|
492
535
|
if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
|
|
@@ -502,25 +545,39 @@ export function planUntrack(root, expectedHashes = {}) {
|
|
|
502
545
|
if (!candidates.length || !localStat(root)) return { root, paths: [], hashes: {}, index: '', conflicts: [] };
|
|
503
546
|
const result = gitCommand(root, ['ls-files', '--stage', '-z']);
|
|
504
547
|
if (result.status !== 0) {
|
|
505
|
-
if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || ''))
|
|
548
|
+
if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || '')) {
|
|
549
|
+
// Fail closed if a repository exists even when Git cannot be launched.
|
|
550
|
+
let directory = root;
|
|
551
|
+
while (true) {
|
|
552
|
+
if (localStat(join(directory, '.git'))) throw new Error('Nao foi possivel consultar o Git deste projeto; indice e arquivos preservados.');
|
|
553
|
+
const parent = dirname(directory);
|
|
554
|
+
if (parent === directory) break;
|
|
555
|
+
directory = parent;
|
|
556
|
+
}
|
|
557
|
+
return { root, paths: [], hashes: {}, index: '', conflicts: [] };
|
|
558
|
+
}
|
|
506
559
|
throw new Error(result.error?.message || result.stderr || 'Falha ao ler indice Git.');
|
|
507
560
|
}
|
|
508
561
|
const conflicts = [];
|
|
509
562
|
const records = result.stdout.split('\0').filter(Boolean);
|
|
510
|
-
const
|
|
563
|
+
const folded = new Map();
|
|
564
|
+
for (const path of candidates) folded.set(path.toLowerCase(), [...(folded.get(path.toLowerCase()) || []), path]);
|
|
511
565
|
const matches = new Map();
|
|
512
566
|
for (const record of records) {
|
|
513
567
|
const position = record.indexOf('\t'), path = record.slice(position + 1);
|
|
514
|
-
if (position < 0
|
|
515
|
-
|
|
568
|
+
if (position < 0) continue;
|
|
569
|
+
for (const candidate of folded.get(path.toLowerCase()) || []) {
|
|
570
|
+
matches.set(candidate, [...(matches.get(candidate) || []), record]);
|
|
571
|
+
}
|
|
516
572
|
}
|
|
517
573
|
const paths = [...matches.keys()].filter(path => {
|
|
518
574
|
const file = checkedLocalPath(join(root, path));
|
|
519
575
|
const entries = matches.get(path);
|
|
520
576
|
const match = entries.length === 1 && entries[0].match(/^\d+ ([a-f0-9]+) 0\t/);
|
|
521
|
-
const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]]) : null;
|
|
522
|
-
const
|
|
523
|
-
const
|
|
577
|
+
const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]], undefined, null) : null;
|
|
578
|
+
const data = localStat(file) ? readFileSync(file) : null;
|
|
579
|
+
const owned = data && createHash('sha256').update(data).digest('hex') === expectedHashes[path];
|
|
580
|
+
const indexOwned = entries[0]?.slice(entries[0].indexOf('\t') + 1) === path && blob?.status === 0 && data && indexContentMatches(root, path, data, blob.stdout);
|
|
524
581
|
if (!owned || !indexOwned) conflicts.push(path);
|
|
525
582
|
return owned && indexOwned;
|
|
526
583
|
});
|
|
@@ -12,6 +12,9 @@ node --test .pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs
|
|
|
12
12
|
|
|
13
13
|
`--check` só confere; a execução normal atualiza os recursos. `--force` salva
|
|
14
14
|
conflitos em `.agent-tests/` antes de reparar os caminhos gerenciados.
|
|
15
|
+
O [comando agêntico](../../commands/sincronizar/COMMAND.md) usa `--force` por
|
|
16
|
+
padrão; executar este script diretamente não ativa essa opção. A CLI oferece
|
|
17
|
+
`sync --dry-run --json` para consultar o plano antes de aplicá-lo.
|
|
15
18
|
Recursos nativos humanos recebem um bloco curto com a referência ao hub.
|
|
16
19
|
Texto, frontmatter e nomes existentes são preservados. O estado guarda só o
|
|
17
20
|
hash desse bloco; edições externas continuam permitidas. Um bloco alterado
|
|
@@ -35,12 +38,14 @@ Após a instalação, depende somente de Node e do hub local, sem o pacote npm.
|
|
|
35
38
|
Ele respeita os clientes escolhidos, sem recriar os desmarcados. Para trocar
|
|
36
39
|
a seleção, use `agentdevkit agents <lista>`; não edite o manifesto gerado.
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
presença de um bloco gerenciado.
|
|
41
|
+
O bloco local em `.git/info/exclude` ignora as pastas de clientes por padrão;
|
|
42
|
+
sem Git, a proteção fica pendente até `git init` e sincronização. Para incluir
|
|
43
|
+
uma pasta, use uma exceção literal no `.gitignore` da raiz, como `!/.claude/`.
|
|
44
|
+
Isso também vale para `.github` e `.vscode`, que podem conter arquivos humanos.
|
|
45
|
+
Arquivos já rastreados e o índice não são alterados automaticamente.
|
|
44
46
|
Entradas novas usam os templates; hashes em `state/derived.json` permitem
|
|
45
47
|
atualizar arquivos gerados e blocos separadamente, preservando alterações humanas.
|
|
46
|
-
`--check`
|
|
48
|
+
`--check` somente verifica; não altera o índice.
|
|
49
|
+
Apontamentos obsoletos podem ser removidos quando a propriedade é comprovada;
|
|
50
|
+
arquivos e pastas próprios, inclusive os adicionais dentro do mesmo recurso,
|
|
51
|
+
devem permanecer.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, posix, resolve } from 'node:path';
|
|
5
5
|
import { randomUUID, createHash } from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { requireNode, projectRoot, isMain } from '../lib/runtime.mjs';
|
|
7
8
|
import { buildCatalog } from '../lib/catalog.mjs';
|
|
8
9
|
import { resourcePath } from '../lib/resource-path.mjs';
|
|
@@ -10,7 +11,7 @@ import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
|
|
|
10
11
|
import { nativePathBoundary } from '../lib/native-paths.mjs';
|
|
11
12
|
import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
|
|
12
13
|
import { mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
|
|
13
|
-
import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
|
|
14
|
+
import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, planUntrack, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
|
|
14
15
|
import { stateDirectory } from '../lib/state-path.mjs';
|
|
15
16
|
|
|
16
17
|
const marker = '<!-- gerado: sincronizar-agentes -->';
|
|
@@ -79,6 +80,28 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
79
80
|
requireNode();
|
|
80
81
|
root = realpathSync(resolve(root));
|
|
81
82
|
const absolute = value => join(root, ...value.split('/'));
|
|
83
|
+
function assertGitReadableForChanges() {
|
|
84
|
+
// A project may be nested in a worktree; .git can be a file or directory.
|
|
85
|
+
// If that marker exists we must not treat an unavailable Git executable,
|
|
86
|
+
// an invalid repository or an unreadable index as a Git-free project.
|
|
87
|
+
let folder = root, found = false;
|
|
88
|
+
while (true) {
|
|
89
|
+
if (stat(join(folder, '.git'))) { found = true; break; }
|
|
90
|
+
const parent = dirname(folder);
|
|
91
|
+
if (parent === folder) break;
|
|
92
|
+
folder = parent;
|
|
93
|
+
}
|
|
94
|
+
if (!found) return false;
|
|
95
|
+
const env = { ...process.env };
|
|
96
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[key];
|
|
97
|
+
const probe = spawnSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
|
|
98
|
+
encoding: 'utf8', env, windowsHide: true, timeout: 20000,
|
|
99
|
+
});
|
|
100
|
+
if (probe.error || probe.status !== 0 || probe.stdout.trim() !== 'true') {
|
|
101
|
+
fail('Nao foi possivel consultar o Git deste projeto; sincronizacao bloqueada para preservar arquivos e indice.');
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
82
105
|
function safe(value) {
|
|
83
106
|
if (typeof value !== 'string' || !value || /^[A-Za-z]:/.test(value) || /[|\\\x00-\x1f\x7f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail(`Caminho invalido: ${value}`);
|
|
84
107
|
let current = root;
|
|
@@ -130,7 +153,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
130
153
|
const recorded = recordedHash === digest;
|
|
131
154
|
return isReference(text) && recorded;
|
|
132
155
|
};
|
|
133
|
-
const onlyReferences = value => { const files = scan(value); return files.length > 0 && ![...preserved.keys()].some(path => path === value || path.startsWith(`${value}/`)) && files.every(file => /\.(md|mdc|toml)$/i.test(file) && referenceFile(file)); };
|
|
134
156
|
const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
|
|
135
157
|
const agents = new Map();
|
|
136
158
|
const formats = new Map();
|
|
@@ -295,6 +317,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
295
317
|
const remove = new Set();
|
|
296
318
|
const backup = new Set();
|
|
297
319
|
const conflicts = new Set();
|
|
320
|
+
const protectedConflicts = new Set();
|
|
298
321
|
const expected = new Map();
|
|
299
322
|
const managedBlocks = {};
|
|
300
323
|
const generatedFiles = {};
|
|
@@ -307,17 +330,31 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
307
330
|
function planRemove(value, custom = false) {
|
|
308
331
|
safe(value);
|
|
309
332
|
if (!managed(value) || protectedPath(value)) fail(`Limpeza fora dos espelhos ou caminho protegido: ${value}`);
|
|
333
|
+
// Directories, unowned files and edited references can contain project
|
|
334
|
+
// data even when --force is used. Only a recorded, unchanged reference
|
|
335
|
+
// may be removed; empty directories are never removed recursively.
|
|
336
|
+
if (!stat(safe(value))?.isFile() || !referenceFile(value)) {
|
|
337
|
+
remember(value, 'existing-file');
|
|
338
|
+
if (custom) { conflicts.add(value); protectedConflicts.add(value); }
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
310
341
|
remove.add(value);
|
|
311
342
|
if (custom) backup.add(value);
|
|
312
343
|
}
|
|
313
344
|
function cleanScope(value) {
|
|
314
345
|
if (preserveBoundary(value)) return;
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
346
|
+
const info = stat(safe(value));
|
|
347
|
+
if (!info) return;
|
|
348
|
+
const files = info.isDirectory() ? scan(value) : [value];
|
|
349
|
+
if (info.isDirectory() && !files.length) { remember(value, 'existing-directory'); return; }
|
|
350
|
+
let hasHumanContent = false;
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
if (expected.has(file) || priorBlocks[file] || protectedPath(file)) continue;
|
|
353
|
+
if (referenceFile(file)) planRemove(file);
|
|
354
|
+
else if (claimed(file)) { remember(file, 'edited-generated-file'); hasHumanContent = true; }
|
|
355
|
+
else { remember(file); hasHumanContent = true; }
|
|
356
|
+
}
|
|
357
|
+
if (hasHumanContent && info.isDirectory()) remember(value);
|
|
321
358
|
}
|
|
322
359
|
const registries = new Map();
|
|
323
360
|
const discoveryOutputs = {};
|
|
@@ -551,6 +588,22 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
551
588
|
const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
|
|
552
589
|
const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
|
|
553
590
|
const configChanged = !configExists || configText !== finalConfig;
|
|
591
|
+
// Every planned write/removal may cover a different staged version. Compare
|
|
592
|
+
// the worktree bytes with the index before touching any path, even in force
|
|
593
|
+
// mode, and repeat immediately before applying the transaction.
|
|
594
|
+
const affected = sorted([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
|
|
595
|
+
const emptyHash = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
|
|
596
|
+
const affectedHashes = Object.fromEntries(affected.flatMap(value => {
|
|
597
|
+
const info = stat(safe(value));
|
|
598
|
+
if (info && !info.isFile()) return []; // A type conflict already blocks application.
|
|
599
|
+
return [[value, info ? createHash('sha256').update(readFileSync(absolute(value))).digest('hex') : emptyHash]];
|
|
600
|
+
}));
|
|
601
|
+
const hadGit = Object.keys(affectedHashes).length ? assertGitReadableForChanges() : false;
|
|
602
|
+
const affectedIndex = planUntrack(root, affectedHashes);
|
|
603
|
+
for (const path of affectedIndex.conflicts) {
|
|
604
|
+
conflicts.add(path);
|
|
605
|
+
remember(path, 'staged-content-preserved');
|
|
606
|
+
}
|
|
554
607
|
function capture(value) {
|
|
555
608
|
const file = safe(value), info = stat(file);
|
|
556
609
|
if (!info) return null;
|
|
@@ -576,15 +629,15 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
576
629
|
let parent = posix.dirname(value);
|
|
577
630
|
while (parent !== '.') { if (stat(safe(parent))?.isDirectory()) originalDirectories.add(parent); parent = posix.dirname(parent); }
|
|
578
631
|
}
|
|
579
|
-
const touched = new Set(), createdDirectories = new Set()
|
|
580
|
-
const prunedDirectories = new Map();
|
|
581
|
-
let restoring = false;
|
|
632
|
+
const touched = new Set(), createdDirectories = new Set();
|
|
582
633
|
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) };
|
|
583
634
|
const dirty = Object.values(summary).some(Boolean);
|
|
584
635
|
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.`);
|
|
585
636
|
if (localStatus.status === 'no-git') output('Git ausente: apos git init, execute a sincronizacao para proteger os arquivos locais.');
|
|
586
637
|
const preservedPaths = sorted(preserved.keys()).map(path => preserved.get(path));
|
|
587
638
|
if (check) return { ...summary, ownedOutputs, managedBlocks, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
|
|
639
|
+
if (affectedIndex.conflicts.length) fail(`Indice Git contem alteracoes proprias; arquivos preservados: ${sorted(affectedIndex.conflicts).join(', ')}`);
|
|
640
|
+
if (protectedConflicts.size) fail(`Conteudo proprio preservado; resolva a colisao antes de sincronizar: ${sorted(protectedConflicts).join(', ')}`);
|
|
588
641
|
if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
|
|
589
642
|
if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
|
|
590
643
|
if (localPlan) assertLocalIgnoreUnchanged(localPlan);
|
|
@@ -594,7 +647,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
594
647
|
while (parent !== '.' && !stat(safe(parent))) { missing.push(parent); parent = posix.dirname(parent); }
|
|
595
648
|
for (const directory of missing.reverse()) {
|
|
596
649
|
mkdirSync(safe(directory)); createdDirectories.add(directory);
|
|
597
|
-
if (restoring) restoredDirectories.add(directory);
|
|
598
650
|
}
|
|
599
651
|
}
|
|
600
652
|
function mutate(value, action, text) {
|
|
@@ -649,40 +701,27 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
649
701
|
}
|
|
650
702
|
try {
|
|
651
703
|
if (localPlan) assertLocalIgnoreUnchanged(localPlan);
|
|
704
|
+
if (Object.keys(affectedHashes).length) {
|
|
705
|
+
if (!assertGitReadableForChanges() && hadGit) fail('Git deixou de estar disponivel antes da sincronizacao; arquivos preservados.');
|
|
706
|
+
const current = planUntrack(root, affectedHashes);
|
|
707
|
+
if (current.conflicts.length || current.index !== affectedIndex.index) fail('Indice Git mudou antes da sincronizacao; conteudo preservado.');
|
|
708
|
+
}
|
|
652
709
|
for (const value of removals) mutate(value, () => rmSync(safe(value), { recursive: true, force: true }), null);
|
|
653
710
|
for (const value of changed) mutate(value, () => atomic(value, expected.get(value)), expected.get(value));
|
|
654
711
|
for (const [value, text] of registries) mutate(value, () => atomic(value, text), text);
|
|
655
712
|
for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
|
|
656
713
|
for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
|
|
657
714
|
if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
|
|
658
|
-
//
|
|
659
|
-
for (const value of removals) {
|
|
660
|
-
let parent = posix.dirname(value);
|
|
661
|
-
const boundary = value.split('/').slice(0, 2).join('/');
|
|
662
|
-
while (parent.startsWith(boundary + '/') || parent === boundary) {
|
|
663
|
-
const info = stat(safe(parent));
|
|
664
|
-
if (!info?.isDirectory() || readdirSync(absolute(parent)).length) break;
|
|
665
|
-
rmdirSync(safe(parent)); prunedDirectories.set(parent, info.mode & 0o777); parent = posix.dirname(parent);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
715
|
+
// Directory ownership is not recorded: keep even empty parent directories.
|
|
668
716
|
if (localPlan) applyLocalIgnore(localPlan);
|
|
669
717
|
} catch (error) {
|
|
670
718
|
const failures = [];
|
|
671
|
-
restoring = true;
|
|
672
719
|
for (const value of [...touched].reverse()) {
|
|
673
720
|
try {
|
|
674
721
|
if (fingerprint(capture(value)) !== currentHashes.get(value)) fail(`Mudanca concorrente preservada: ${value}`);
|
|
675
722
|
restore(value, originals.get(value));
|
|
676
723
|
} catch (failure) { failures.push(failure); }
|
|
677
724
|
}
|
|
678
|
-
for (const [directory, mode] of [...prunedDirectories].sort(([left], [right]) => right.length - left.length)) {
|
|
679
|
-
try {
|
|
680
|
-
if (!stat(safe(directory))) ensureDirectory(`${directory}/.restore-probe`);
|
|
681
|
-
const info = stat(safe(directory));
|
|
682
|
-
if (!info?.isDirectory() || !restoredDirectories.has(directory) && (info.mode & 0o777) !== mode) fail(`Diretorio concorrente preservado: ${directory}`);
|
|
683
|
-
chmodSync(safe(directory), mode);
|
|
684
|
-
} catch (failure) { failures.push(failure); }
|
|
685
|
-
}
|
|
686
725
|
for (const directory of sorted(createdDirectories).sort((a, b) => b.length - a.length)) {
|
|
687
726
|
try { if (!originalDirectories.has(directory) && stat(safe(directory))?.isDirectory() && !readdirSync(absolute(directory)).length) rmdirSync(safe(directory)); }
|
|
688
727
|
catch (failure) { failures.push(failure); }
|