@pathbit/agentdevkit 0.0.22 → 0.0.24

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.
Files changed (31) hide show
  1. package/.pbai4devs/rules/ambiente-local/RULE.md +5 -0
  2. package/.pbai4devs/rules/git/RULE.md +5 -1
  3. package/.pbai4devs/scripts/README.md +1 -0
  4. package/.pbai4devs/scripts/inspecionar-projeto/README.md +6 -0
  5. package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +218 -18
  6. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +59 -11
  7. package/.pbai4devs/scripts/sincronizar-agentes/tests/CATALOG_OWNERSHIP.mjs +251 -0
  8. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +33 -13
  9. package/.pbai4devs/scripts/testar-maturidade/README.md +12 -3
  10. package/.pbai4devs/scripts/testar-maturidade/SCRIPT.mjs +65 -25
  11. package/.pbai4devs/scripts/testar-maturidade/tests/TEST.mjs +150 -5
  12. package/.pbai4devs/scripts/verificar-configuracao/README.md +22 -0
  13. package/.pbai4devs/scripts/verificar-configuracao/SCRIPT.mjs +63 -0
  14. package/.pbai4devs/scripts/verificar-configuracao/tests/TEST.mjs +145 -0
  15. package/.pbai4devs/skills/desenvolvimento/implementar-backend/SKILL.md +15 -3
  16. package/.pbai4devs/skills/desenvolvimento/implementar-frontend/SKILL.md +15 -3
  17. package/.pbai4devs/skills/git/git-diff/SKILL.md +3 -1
  18. package/.pbai4devs/skills/git/git-status/SKILL.md +2 -1
  19. package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +13 -8
  20. package/.pbai4devs/skills/projeto/proximos-passos/SKILL.md +4 -2
  21. package/.pbai4devs/skills/specs/implementar-spec/SKILL.md +14 -2
  22. package/CHANGELOG.md +17 -0
  23. package/docs/guides/cli.md +9 -0
  24. package/docs/guides/troubleshooting.md +12 -6
  25. package/docs/specs/README.md +3 -0
  26. package/docs/specs/preimplementation-order/SPEC.md +23 -0
  27. package/docs/specs/target-paths/SPEC.md +19 -0
  28. package/package.json +1 -1
  29. package/src/i18n.mjs +3 -3
  30. package/src/install.mjs +121 -23
  31. package/src/next.mjs +26 -9
@@ -27,6 +27,11 @@ O Makefile deve oferecer `help`, `doctor`, `setup`, `up`, `down`, `logs`,
27
27
  `test`, `lint` e `build`, com comandos reais da stack. Etapas ainda indefinidas
28
28
  devem falhar com mensagem útil; não simule sucesso. Documente os comandos,
29
29
  dependências e URL/porta no README; valide Compose e saúde antes de concluir.
30
+ Antes de existir aplicação, valide o runtime, o Compose e os serviços de
31
+ infraestrutura necessários. Build, saúde e fluxo da aplicação inexistente
32
+ permanecem pendentes; não crie código de negócio para simular essas provas. Se
33
+ uma dependência obrigatória estiver indisponível, mantenha a configuração
34
+ pendente e registre o impedimento. Após a implementação, prove a aplicação.
30
35
 
31
36
  Use o [modelo de ambiente](../../templates/ambiente-local/TEMPLATE.md) para
32
37
  perguntas e arquivos-base. Versões da aplicação seguem as decisões do projeto;
@@ -1,10 +1,14 @@
1
1
  ---
2
2
  trigger: model_decision
3
- description: Aplique ao preparar, integrar ou publicar alterações Git.
3
+ description: Aplique ao consultar, preparar, integrar ou publicar alterações Git.
4
4
  ---
5
5
 
6
6
  # Regras Git
7
7
 
8
+ Em consultas sem alterações, defina `GIT_OPTIONAL_LOCKS=0` no ambiente do
9
+ subprocesso para evitar atualizações opcionais dos bytes do índice. Isso não
10
+ substitui as travas necessárias para operações que alteram o Git.
11
+
8
12
  Use Conventional Commits (`tipo(escopo): descrição`, escopo opcional), inclusive
9
13
  em checkpoints e merges; destaque mudanças incompatíveis com `!` ou
10
14
  `BREAKING CHANGE:`. Autor e committer devem ser o usuário, nunca um agente;
@@ -26,6 +26,7 @@ node .pbai4devs/scripts/executar-script/SCRIPT.mjs diagnosticar-ambiente
26
26
  | --- | --- |
27
27
  | `diagnosticar-ambiente` | Runtimes, pacotes e navegadores disponíveis |
28
28
  | `inspecionar-projeto` | Detecta manifests, comandos e estrutura |
29
+ | `verificar-configuracao` | Confere o estado antes de implementar código |
29
30
  | `preparar-ferramentas` | Instala/atualiza ferramentas locais |
30
31
  | `gerenciar-skills-externas` | Instala/check/read de skills curadas locais |
31
32
  | `auditar-seguranca` | Scanners offline, bases locais e relatórios redigidos |
@@ -12,6 +12,10 @@ O JSON identifica tecnologia, comandos, estrutura e documentos existentes.
12
12
  `documentation.files` mantém caminhos e caixa, papel provável, tamanho e hash
13
13
  quando a leitura couber nos limites. Referências válidas do estado aparecem em
14
14
  `configured`; nomes sugerem papéis, mas não comprovam conteúdo ou aprovação.
15
+ Na maturidade, o roadmap e as specs são consultados nos caminhos configurados;
16
+ status são declarações e um vínculo local não comprova aprovação ou revisão.
17
+ Uma lista de specs ambígua ou incompleta não autoriza escolher um incremento;
18
+ o estado do vínculo no roadmap também é apenas uma indicação para consulta.
15
19
 
16
20
  Links simbólicos são listados como opacos, sem ler seus destinos. Conteúdo dos
17
21
  documentos e valores de configuração não aparecem no relatório. `scan` e
@@ -20,6 +24,8 @@ condições. Uma pasta simbólica como `docs` também aparece, sem presumir que
20
24
  esteja vazia. Links não classificados recebem `role: unclassified`; enquanto
21
25
  existir uma fronteira não inspecionada, `scan.complete` será falso. Não use
22
26
  inventário incompleto como prova de ausência de documentos.
27
+ O inspetor revalida a identidade dos caminhos durante a leitura; isso detecta
28
+ mudanças, mas não é uma garantia atômica contra toda troca simultânea.
23
29
 
24
30
  `exclusions` descreve pastas de clientes, artefatos e caches fora do inventário.
25
31
  Documentos humanos em `.github`, exemplos, wiki e RFCs são incluídos; os
@@ -41,12 +41,69 @@ const present = (root, name, directory) => {
41
41
  const inside = candidate => { const relative = path.relative(repository, candidate); return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); };
42
42
  const fixturePath = candidate => /^(?:\.agent-tests\/|tmp\/examples\/|examples\/generated\/)/.test(normalize(path.relative(repository, candidate)));
43
43
 
44
+ // Rechecking is a conservative race detection, not an atomic openat traversal.
45
+ function pathIdentity(root, file = '') {
46
+ try {
47
+ let current = root; const result = [], parts = file ? file.split('/') : [];
48
+ for (const [index, part] of ['', ...parts].entries()) {
49
+ if (part) current = path.join(current, part);
50
+ const item = fs.lstatSync(current);
51
+ if (item.isSymbolicLink() || (index < parts.length && !item.isDirectory())) return null;
52
+ result.push({ dev: item.dev, ino: item.ino, mode: item.mode, size: item.size, directory: item.isDirectory() });
53
+ }
54
+ return result;
55
+ } catch { return null; }
56
+ }
57
+ const sameIdentity = (left, right) => left && right && left.length === right.length
58
+ && left.every((item, index) => item.dev === right[index].dev && item.ino === right[index].ino && item.mode === right[index].mode);
59
+
60
+ function boundedEntries(folder, limit, verify = () => true) {
61
+ const handle = fs.opendirSync(folder), entries = [];
62
+ try {
63
+ if (!verify()) return { entries: [], truncated: true, changed: true };
64
+ while (true) {
65
+ const entry = handle.readSync();
66
+ if (!entry) return verify() ? { entries, truncated: false } : { entries: [], truncated: true, changed: true };
67
+ if (entries.length >= limit) return verify() ? { entries, truncated: true } : { entries: [], truncated: true, changed: true };
68
+ entries.push(entry);
69
+ }
70
+ } finally { handle.closeSync(); }
71
+ }
72
+
73
+ function stableDirectory(root, directory, limit) {
74
+ const identity = pathIdentity(root, directory);
75
+ if (!identity?.at(-1).directory) return { entries: [], truncated: true, changed: true, verify: () => false };
76
+ const verify = () => sameIdentity(identity, pathIdentity(root, directory));
77
+ return { ...boundedEntries(path.join(root, directory), limit, verify), verify };
78
+ }
79
+
44
80
  function metadata(root, file) {
45
- if (!present(root, file, false) || fs.statSync(path.join(root, file)).size > 1024 * 1024) return { fields: {}, documents: {} };
46
- const text = fs.readFileSync(path.join(root, file), 'utf8').replaceAll('\r', '');
81
+ const empty = { fields: {}, documents: {}, text: null };
82
+ const identity = () => pathIdentity(root, file);
83
+ let descriptor, text;
84
+ try {
85
+ const name = path.join(root, file), ancestors = identity();
86
+ if (!ancestors || !sameIdentity(ancestors, identity())) return empty;
87
+ const before = ancestors.at(-1);
88
+ if (before.size > 1024 * 1024) return empty;
89
+ descriptor = fs.openSync(name, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
90
+ const opened = fs.fstatSync(descriptor);
91
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || !sameIdentity(ancestors, identity())) return empty;
92
+ const buffer = Buffer.alloc(opened.size + 1); let length = 0;
93
+ while (length < buffer.length) {
94
+ const count = fs.readSync(descriptor, buffer, length, buffer.length - length, length);
95
+ if (!count) break;
96
+ length += count;
97
+ }
98
+ const after = fs.fstatSync(descriptor);
99
+ if (length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || !sameIdentity(ancestors, identity())) return empty;
100
+ text = buffer.subarray(0, length).toString('utf8').replaceAll('\r', '');
101
+ } catch { return empty; }
102
+ finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
47
103
  const header = text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)?.[1];
48
- const result = { fields: {}, documents: {} }; if (!header) return result;
49
- let documents = false;
104
+ const result = { fields: {}, documents: {}, text }; if (!header) return result;
105
+ let documents = false, invalidDocuments = false;
106
+ const seen = new Set(), duplicates = new Set(), seenDocuments = new Set(), duplicateDocuments = new Set();
50
107
  const scalar = raw => {
51
108
  const value = raw.trim();
52
109
  if (!value || ['null', '~'].includes(value)) return null;
@@ -54,16 +111,131 @@ function metadata(root, file) {
54
111
  return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1).replaceAll("''", "'") : value;
55
112
  };
56
113
  for (const line of header.split('\n')) {
57
- const top = line.match(/^([a-z_]+):\s*(.*)$/);
58
- if (top) { documents = top[1] === 'documentos'; result.fields[top[1]] = scalar(top[2]); }
59
- else if (documents) { const item = line.match(/^ ([a-z_]+):\s*(.*)$/); if (item) result.documents[item[1]] = scalar(item[2]); }
114
+ if (!line.trim() || /^\s*#/.test(line)) continue;
115
+ const top = line.match(/^(?:([\p{L}_][\p{L}\p{N}_.-]*)|"([^"\n]+)"|'([^'\n]+)'):\s*(.*)$/u);
116
+ if (top) {
117
+ const key = top[1] || top[2] || top[3];
118
+ if (key.includes('\\')) return { ...empty, text };
119
+ if (seen.has(key)) duplicates.add(key);
120
+ seen.add(key); result.fields[key] = duplicates.has(key) ? null : scalar(top[4]);
121
+ if (key === 'documentos' && duplicates.has(key)) { invalidDocuments = true; result.documents = {}; }
122
+ documents = key === 'documentos' && !invalidDocuments && (!top[4].trim() || ['null', '~'].includes(top[4].trim()));
123
+ } else if (!/^\s/.test(line)) return { ...empty, text };
124
+ else if (documents) {
125
+ const item = line.match(/^ (?:([\p{L}_][\p{L}\p{N}_.-]*)|"([^"\n]+)"|'([^'\n]+)'):\s*(.*)$/u);
126
+ if (item) {
127
+ const key = item[1] || item[2] || item[3];
128
+ if (key.includes('\\')) { invalidDocuments = true; result.documents = {}; documents = false; continue; }
129
+ if (seenDocuments.has(key)) duplicateDocuments.add(key);
130
+ seenDocuments.add(key); result.documents[key] = duplicateDocuments.has(key) ? null : scalar(item[4]);
131
+ } else if (/^ \S/.test(line)) { invalidDocuments = true; result.documents = {}; documents = false; }
132
+ }
60
133
  }
61
134
  return result;
62
135
  }
63
136
 
137
+ // The configured specs directory contains <slug>/SPEC.md. Refuse ambiguous
138
+ // layouts instead of guessing which increment or declared status to use.
139
+ function configuredSpecs(root, directory) {
140
+ const result = { path: directory, present: false, complete: false, items: [] };
141
+ if (!directory) return result;
142
+ let current = root;
143
+ for (const component of directory.split('/')) {
144
+ current = path.join(current, component);
145
+ try {
146
+ const info = fs.lstatSync(current);
147
+ if (info.isSymbolicLink() || !info.isDirectory()) return result;
148
+ } catch (error) {
149
+ if (error.code === 'ENOENT') result.complete = true;
150
+ return result;
151
+ }
152
+ }
153
+ result.present = true;
154
+ const incomplete = () => { result.items = []; return result; };
155
+ let scanned = 0;
156
+ function entriesAt(folder) {
157
+ const relative = normalize(path.relative(root, folder));
158
+ const result = stableDirectory(root, relative, Math.min(200, 500 - scanned));
159
+ scanned += result.entries.length;
160
+ return result.truncated ? null : result.entries;
161
+ }
162
+ try {
163
+ const entries = entriesAt(current);
164
+ if (!entries) return incomplete();
165
+ for (const entry of entries) {
166
+ if (/^readme(?:\.md)?$/i.test(entry.name) && !entry.isSymbolicLink()) continue;
167
+ if (['.git', '.pbai4devs', '.agent-tests', 'node_modules'].includes(entry.name.toLowerCase())) return incomplete();
168
+ if (!entry.isDirectory() || entry.isSymbolicLink()) return incomplete();
169
+ const folder = `${directory}/${entry.name}`;
170
+ if (!present(root, folder, true)) return incomplete();
171
+ const children = entriesAt(path.join(root, folder));
172
+ if (!children) return incomplete();
173
+ const names = children.filter(item => /^spec\.md$/i.test(item.name));
174
+ if (names.length !== 1 || !names[0].isFile() || names[0].isSymbolicLink()) return incomplete();
175
+ const file = `${folder}/${names[0].name}`;
176
+ if (!present(root, file, false)) return incomplete();
177
+ const document = metadata(root, file);
178
+ if (document.text === null) return incomplete();
179
+ result.items.push({ path: file, status: ['rascunho', 'pendente', 'aprovado'].includes(document.fields.status) ? document.fields.status : null });
180
+ if (result.items.length > 1) return incomplete();
181
+ }
182
+ if (!present(root, directory, true)) return incomplete();
183
+ result.complete = true;
184
+ } catch { return incomplete(); }
185
+ return result;
186
+ }
187
+
188
+ function referenceState(roadmap, roadmapFile, specFile) {
189
+ if (!roadmap || !roadmapFile) return { linked: false, roadmapState: 'unknown' };
190
+ // Examples and orphan reference definitions do not select an increment.
191
+ const lines = []; let fence = null;
192
+ const body = roadmap.replace(/^---\n[^]*?\n---(?:\n|$)/, '').replace(/<!--[^]*?-->/g, '');
193
+ for (const line of body.split('\n')) {
194
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
195
+ if (fence) {
196
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !line.slice(marker[0].length).trim()) fence = null;
197
+ } else if (marker) fence = marker[1];
198
+ else lines.push(line.replace(/(`+)[^`\n]*?\1/g, ''));
199
+ }
200
+ const normalizeLabel = label => label.trim().replace(/\s+/g, ' ').toLowerCase();
201
+ const definitions = new Map();
202
+ for (const line of lines) {
203
+ const match = line.match(/^ {0,3}\[([^\]]+)\]:[ \t]*(?:<([^>\n]+)>|(\S+))(?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'))?[ \t]*$/);
204
+ if (match) { const label = normalizeLabel(match[1]); definitions.set(label, [...(definitions.get(label) || []), match[2] || match[3]]); }
205
+ }
206
+ const references = [], ancestry = [];
207
+ for (const line of lines) {
208
+ const list = line.match(/^([ \t]*)(?:[-+*]|\d+[.)])\s+(.*)$/);
209
+ const marker = list?.[2].match(/^\[([ xX-])\](?:\s|$)/);
210
+ const closed = /(?:^|[^\p{L}])(?:cancelad[ao]s?|conclu[ií]d[ao]s?|publicad[ao]s?|finalizad[ao]s?|descartad[ao]s?|cancelled|completed|published)(?=$|[^\p{L}])/iu.test(line);
211
+ let state = 'unknown';
212
+ if (list) {
213
+ const indent = list[1].length;
214
+ while (ancestry.length && ancestry.at(-1).indent >= indent) ancestry.pop();
215
+ const own = closed || marker && /x/i.test(marker[1]) ? 'closed' : marker?.[1] === ' ' ? 'open' : marker?.[1] === '-' ? 'in-progress' : 'unknown';
216
+ state = ancestry.some(item => item.state === 'closed') ? 'closed' : /\t/.test(list[1]) || ancestry.some(item => item.state === 'unknown') ? 'unknown' : own;
217
+ ancestry.push({ indent, state });
218
+ } else if (line.trim() && !/^\s/.test(line)) ancestry.length = 0;
219
+ for (const match of line.matchAll(/(?<![!\\])\[[^\]\n]+\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\s*\)/g)) references.push({ destination: match[1] || match[2], state });
220
+ for (const match of line.matchAll(/(?<![!\\])\[([^\]\n]+)\]\[([^\]\n]*)\]/g)) {
221
+ const label = normalizeLabel(match[2] || match[1]);
222
+ if (definitions.get(label)?.length === 1) references.push({ destination: definitions.get(label)[0], state });
223
+ }
224
+ }
225
+ const matches = references.filter(({ destination }) => {
226
+ try {
227
+ const value = decodeURIComponent(destination).split(/[?#]/, 1)[0];
228
+ if (!value || /^(?:[a-z][a-z0-9+.-]*:|\/|\\)/i.test(value) || value.includes('\\')) return false;
229
+ return path.posix.resolve('/', path.posix.dirname(roadmapFile), value) === '/' + specFile;
230
+ } catch { return false; }
231
+ });
232
+ if (matches.length !== 1) return { linked: matches.length > 0, roadmapState: matches.length ? 'ambiguous' : 'unknown' };
233
+ return { linked: true, roadmapState: matches[0].state };
234
+ }
235
+
64
236
  function maturity(root, report) {
65
237
  const stateFile = `${stateDirectory(root)}/configuracao/ESTADO.md`; const state = metadata(root, stateFile);
66
- const safeDocument = value => typeof value === 'string' && !path.isAbsolute(value) && !value.includes('\\') && !value.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
238
+ const safeDocument = value => typeof value === 'string' && !path.isAbsolute(value) && !/[\\\x00-\x1f\x7f:]/.test(value) && !value.split('/').some(part => !part || ['.', '..', '.git'].includes(part.toLowerCase()));
67
239
  const planning = {};
68
240
  for (const name of ['prd', 'ux_ui', 'design']) {
69
241
  const file = state.documents[name];
@@ -72,10 +244,22 @@ function maturity(root, report) {
72
244
  planning[name] = { path: safeDocument(file) ? file : null, present: Boolean(exists), status: ['rascunho', 'aprovado', 'nao-aplicavel', 'pendente'].includes(status) ? status : null };
73
245
  }
74
246
  planning.ready = planning.prd.status === 'aprovado' && planning.design.status === 'aprovado' && ['aprovado', 'nao-aplicavel'].includes(planning.ux_ui.status);
75
- let released = false; const releases = state.documents.releases;
247
+ const roadmapPath = safeDocument(state.documents.roadmap) && /\.md$/i.test(state.documents.roadmap) ? state.documents.roadmap : null;
248
+ const roadmapPresent = Boolean(roadmapPath && present(root, roadmapPath, false));
249
+ const roadmapDocument = roadmapPresent ? metadata(root, roadmapPath) : { fields: {}, text: null };
250
+ planning.roadmap = { path: roadmapPath, present: roadmapPresent, status: ['pendente', 'rascunho', 'planejado', 'aprovado'].includes(roadmapDocument.fields.status) ? roadmapDocument.fields.status : null };
251
+ const specsPath = safeDocument(state.documents.specs) ? state.documents.specs : null;
252
+ planning.specs = configuredSpecs(root, specsPath);
253
+ for (const spec of planning.specs.items) Object.assign(spec, referenceState(roadmapDocument.text, roadmapPath, spec.path));
254
+ let released = false, releasesComplete = true; const releases = state.documents.releases;
76
255
  if (safeDocument(releases) && present(root, releases, true)) {
77
256
  const visit = (directory, depth = 0) => {
78
- for (const entry of fs.readdirSync(path.join(root, directory), { withFileTypes: true }).slice(0, 100)) {
257
+ let listing;
258
+ try { listing = stableDirectory(root, directory, 100); }
259
+ catch { releasesComplete = false; return; }
260
+ if (listing.truncated) releasesComplete = false;
261
+ for (const entry of listing.entries) {
262
+ if (!listing.verify()) { releasesComplete = false; return; }
79
263
  const file = normalize(path.join(directory, entry.name));
80
264
  if (entry.isSymbolicLink()) continue;
81
265
  if (entry.isDirectory() && depth < 2) visit(file, depth + 1);
@@ -91,7 +275,7 @@ function maturity(root, report) {
91
275
  if (planning.ready) level = 'planejado';
92
276
  if (report.mode === 'aplicacao-detectada') level = 'implementacao';
93
277
  if (report.mode === 'aplicacao-detectada' && planning.ready && report.contract.complete && configuration.configuracao_tecnica === 'concluido') level = released ? 'manutencao' : 'configurado';
94
- return { level, planning, configuration, released, signals: { application: report.mode === 'aplicacao-detectada', structure: report.contract.complete }, limits: ['Classificacao descritiva; status em arquivos nao substitui aprovacao, testes ou provas de funcionamento.'] };
278
+ return { level, planning, configuration, released, releasesComplete, signals: { application: report.mode === 'aplicacao-detectada', structure: report.contract.complete }, limits: ['Classificacao descritiva; status em arquivos nao substitui aprovacao, testes ou provas de funcionamento.'] };
95
279
  }
96
280
 
97
281
  const documentRoles = new Set(['readme', 'contribuicao', 'changelog', 'seguranca', 'adrs', 'prd', 'ux_ui', 'design', 'roadmap', 'releases', 'specs', 'tarefas']);
@@ -152,10 +336,12 @@ export function inspectDocumentation(root, limits = {}) {
152
336
  }
153
337
  let descriptor;
154
338
  try {
339
+ const identity = pathIdentity(root, file);
340
+ if (!identity || !sameIdentity(identity, pathIdentity(root, file))) { warn(file, 'changed-during-read'); return; }
155
341
  descriptor = fs.openSync(path.join(root, file), fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
156
342
  const before = fs.fstatSync(descriptor);
157
343
  if (!before.isFile() || before.dev !== expected.dev || before.ino !== expected.ino || before.size !== expected.size
158
- || before.mtimeMs !== expected.mtimeMs || before.ctimeMs !== expected.ctimeMs) { warn(file, 'changed-during-read'); return; }
344
+ || before.mtimeMs !== expected.mtimeMs || before.ctimeMs !== expected.ctimeMs || !sameIdentity(identity, pathIdentity(root, file))) { warn(file, 'changed-during-read'); return; }
159
345
  const buffer = Buffer.alloc(Math.min(before.size + 1, bounds.maxTotalBytes - scan.bytesRead));
160
346
  let length = 0;
161
347
  while (length < buffer.length) {
@@ -165,7 +351,7 @@ export function inspectDocumentation(root, limits = {}) {
165
351
  scan.bytesRead += read;
166
352
  }
167
353
  const after = fs.fstatSync(descriptor);
168
- if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
354
+ if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || !sameIdentity(identity, pathIdentity(root, file))) {
169
355
  warn(file, 'changed-during-read'); return;
170
356
  }
171
357
  scan.bytesHashed += length;
@@ -194,8 +380,13 @@ export function inspectDocumentation(root, limits = {}) {
194
380
  const found = locate(directory);
195
381
  if (found.kind !== 'directory') { if (found.kind === 'symbolic-link') add(found.boundary, found, role); return; }
196
382
  }
197
- let children;
198
- try { children = fs.readdirSync(path.join(root, directory), { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); }
383
+ let children, listing;
384
+ try {
385
+ listing = stableDirectory(root, directory, Math.max(0, bounds.maxEntries - scan.entries));
386
+ if (listing.changed) { scan.truncated = true; warn(directory || '.', 'changed-during-read'); return; }
387
+ if (listing.truncated) scan.truncated = true;
388
+ children = listing.entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
389
+ }
199
390
  catch { warn(directory || '.', 'unreadable-directory'); return; }
200
391
  // Discard a listing if the directory became a link while being inspected.
201
392
  if (directory && locate(directory).kind !== 'directory') { warn(directory, 'changed-during-read'); return; }
@@ -204,6 +395,7 @@ export function inspectDocumentation(root, limits = {}) {
204
395
  scan.entries++;
205
396
  const file = directory ? `${directory}/${child.name}` : child.name;
206
397
  if (excludedDocument(file)) continue;
398
+ if (!listing.verify()) { scan.truncated = true; warn(directory || '.', 'changed-during-read'); return; }
207
399
  const found = locate(file);
208
400
  if (found.kind === 'directory') {
209
401
  if (depth < bounds.maxDepth) walk(file, depth + 1, role);
@@ -244,8 +436,11 @@ export function inspectProject(root = repository, { check = false, documentation
244
436
  const warning = (file, reason) => report.warnings.push({ file, reason });
245
437
  function commands(file, name) {
246
438
  if (!['package.json', 'composer.json', 'deno.json', 'deno.jsonc', 'Makefile', 'makefile', 'GNUmakefile', 'pyproject.toml'].includes(name)) return;
247
- if (fs.statSync(path.join(root, file)).size > 1024 * 1024) { warning(file, 'manifest-grande-demais'); return; }
248
- const text = fs.readFileSync(path.join(root, file), 'utf8');
439
+ const identity = pathIdentity(root, file);
440
+ if (!identity) { warning(file, 'manifest-nao-inspecionado'); return; }
441
+ if (identity.at(-1).size > 1024 * 1024) { warning(file, 'manifest-grande-demais'); return; }
442
+ const text = metadata(root, file).text;
443
+ if (text === null) { warning(file, 'manifest-nao-inspecionado'); return; }
249
444
  let names = [];
250
445
  try {
251
446
  if (['package.json', 'composer.json', 'deno.json', 'deno.jsonc'].includes(name)) {
@@ -268,7 +463,10 @@ export function inspectProject(root = repository, { check = false, documentation
268
463
  } catch { warning(file, 'manifest-nao-interpretado'); }
269
464
  }
270
465
  function walk(directory = '', depth = 0) {
271
- const children = fs.readdirSync(path.join(root, directory), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
466
+ const listing = stableDirectory(root, directory, Math.max(0, 25000 - entries));
467
+ if (listing.changed) { report.scan.truncated = true; warning(directory || '.', 'changed-during-read'); return; }
468
+ if (listing.truncated) report.scan.truncated = true;
469
+ const children = listing.entries.sort((a, b) => a.name.localeCompare(b.name));
272
470
  for (const item of children) {
273
471
  if (++entries > 25000) { report.scan.truncated = true; return; }
274
472
  const file = normalize(path.join(directory, item.name));
@@ -276,10 +474,12 @@ export function inspectProject(root = repository, { check = false, documentation
276
474
  if (item.isSymbolicLink()) continue;
277
475
  if (item.isDirectory()) {
278
476
  if (excluded.has(item.name) || excluded.has(item.name.toLowerCase()) || /^\.?playwright/i.test(item.name)) continue;
477
+ if (!listing.verify()) { report.scan.truncated = true; warning(directory || '.', 'changed-during-read'); return; }
279
478
  if (depth < report.scan.maxDepth) walk(file, depth + 1); else report.scan.truncated = true;
280
479
  continue;
281
480
  }
282
481
  if (!item.isFile()) continue;
482
+ if (!listing.verify()) { report.scan.truncated = true; warning(directory || '.', 'changed-during-read'); return; }
283
483
  let language = manifests.get(item.name);
284
484
  if (/\.(?:csproj|fsproj|vbproj|sln)$/.test(item.name)) language = 'dotnet';
285
485
  if (/^requirements(?:[.-][A-Za-z0-9_-]+)?\.txt$/.test(item.name)) language = 'python';
@@ -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.