@pathbit/agentdevkit 0.0.7 → 0.0.9

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 (58) hide show
  1. package/.pbai4devs/COMPATIBILIDADE.md +4 -2
  2. package/.pbai4devs/INSTRUCTIONS.md +4 -0
  3. package/.pbai4devs/commands/sincronizar/COMMAND.md +4 -3
  4. package/.pbai4devs/rules/documentacao/RULE.md +4 -0
  5. package/.pbai4devs/rules/qualidade/RULE.md +4 -2
  6. package/.pbai4devs/scripts/README.md +3 -3
  7. package/.pbai4devs/scripts/inspecionar-projeto/README.md +31 -0
  8. package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +142 -1
  9. package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +168 -1
  10. package/.pbai4devs/scripts/lib/gitignore.mjs +84 -15
  11. package/.pbai4devs/scripts/lib/native-paths.mjs +35 -0
  12. package/.pbai4devs/scripts/preparar-ferramentas/SCRIPT.mjs +41 -20
  13. package/.pbai4devs/scripts/preparar-ferramentas/tests/TEST.mjs +81 -0
  14. package/.pbai4devs/scripts/sincronizar-agentes/README.md +3 -0
  15. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +193 -49
  16. package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +190 -0
  17. package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +151 -0
  18. package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +145 -1
  19. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +30 -29
  20. package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +56 -8
  21. package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +92 -1
  22. package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +2 -0
  23. package/.pbai4devs/skills/produto/criar-prd/SKILL.md +2 -0
  24. package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +2 -0
  25. package/.pbai4devs/skills/projeto/atualizar-documentacao/SKILL.md +18 -4
  26. package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +2 -0
  27. package/.pbai4devs/skills/projeto/criar-recurso/SKILL.md +1 -0
  28. package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +3 -2
  29. package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -0
  30. package/.pbai4devs/skills/specs/gerenciar-roadmap/SKILL.md +2 -1
  31. package/.pbai4devs/templates/README.md +4 -1
  32. package/.pbai4devs/templates/configuracao/TEMPLATE.md +6 -0
  33. package/CHANGELOG.md +19 -0
  34. package/README.en.md +3 -2
  35. package/README.es.md +3 -2
  36. package/README.md +3 -2
  37. package/docs/README.md +1 -0
  38. package/docs/guides/README.md +1 -0
  39. package/docs/guides/cli.md +11 -0
  40. package/docs/guides/documentation.md +63 -0
  41. package/docs/guides/installation.md +18 -5
  42. package/docs/guides/resources.md +6 -5
  43. package/docs/guides/tools-and-testing.md +2 -0
  44. package/docs/guides/troubleshooting.md +6 -6
  45. package/docs/guides/workflow.md +2 -0
  46. package/docs/maintainers/packages.md +1 -1
  47. package/docs/product/design.md +12 -0
  48. package/docs/product/prd.md +4 -1
  49. package/docs/product/roadmap.md +4 -0
  50. package/docs/specs/README.md +2 -0
  51. package/docs/specs/v0.0.8/README.md +10 -0
  52. package/docs/specs/v0.0.8/SPEC.md +62 -0
  53. package/docs/specs/v0.0.9/README.md +9 -0
  54. package/docs/specs/v0.0.9/SPEC.md +68 -0
  55. package/package.json +3 -2
  56. package/src/cli.mjs +13 -4
  57. package/src/i18n.mjs +3 -0
  58. package/src/install.mjs +145 -38
@@ -87,8 +87,10 @@ remoção em fevereiro de 2027. No Copilot, use skills para rotinas invocáveis.
87
87
 
88
88
  O instalador gera a seleção e a política em `espelhos.conf`; `sincronizar`
89
89
  reconstrói os apontamentos somente com Node e os arquivos do hub instalado.
90
- `--check` não altera arquivos. `--force` faz backup ignorado antes de substituir
91
- conteúdo próprio que conflite com caminhos gerenciados.
90
+ `--check` não altera arquivos. `--force` faz backup ignorado ao reparar
91
+ conflitos gerenciados. Recursos e links humanos dos clientes são preservados;
92
+ uma rota preservada não recebe apontamentos novos. No Zod, `.codex/skills`
93
+ continua apontando para suas skills e o Codex recebe o kit em `.agents/skills`.
92
94
 
93
95
  As fontes confirmam formatos, não execução de cada cliente. Valide descoberta,
94
96
  invocação e resultado no cliente escolhido antes de considerar uma integração
@@ -6,6 +6,10 @@ description: Ordem de trabalho e manutenção dos agentes.
6
6
 
7
7
  Peça a skill pelo nome ou solicite a leitura de seu `SKILL.md`.
8
8
 
9
+ Antes de trabalhar na documentação, use `atualizar-documentacao` para inventariar
10
+ e ler os arquivos existentes. Preserve seus caminhos e conteúdo; complemente
11
+ lacunas e organize por índices, sem substituir documentos pelos modelos.
12
+
9
13
  Antes de instalar ou preparar ferramentas, pergunte quais clientes o usuário
10
14
  usa, se não houver escolha no pedido ou no estado do kit. A CLI aceita
11
15
  `--agents codex,claude`; `agents` consulta ou altera a seleção depois.
@@ -8,9 +8,10 @@ Entrada: sem argumentos usa `--force` para reparar; `--check` só confere.
8
8
  Pré-requisitos: Node.js 24+ e manifesto `.pbai4devs/espelhos.conf` válido.
9
9
 
10
10
  Siga [sincronizar-agentes](../../skills/projeto/sincronizar-agentes/SKILL.md)
11
- e execute o script correspondente pelo seletor. A reconstrução pode sobrescrever
12
- apontamentos e remover extras dos destinos gerenciados; preserva os arquivos
13
- nativos fora desse escopo e mantém backup local do conteúdo próprio removido.
11
+ 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
+ com backup local. Recursos, configurações e links humanos são preservados,
14
+ inclusive quando seus nomes coincidem com os destinos do kit.
14
15
  Após reparar, execute também `--check`.
15
16
 
16
17
  Respeite os clientes registrados no manifesto. Para mudar a seleção salva,
@@ -5,6 +5,10 @@ description: Documentação e revisão fazem parte de toda entrega implementada.
5
5
 
6
6
  # Documentação da entrega
7
7
 
8
+ Antes de criar, organizar ou alterar documentação, use `atualizar-documentacao`:
9
+ inventarie e leia o que já existe. Reutilize caminhos e formatos;
10
+ complemente lacunas sem apagar, mover ou substituir arquivos por templates.
11
+
8
12
  Após implementar, siga `atualizar-documentacao` e o comando `revisar-entrega`
9
13
  antes de commit ou merge. Use os modelos da fase atual; registre decisões sem
10
14
  impacto com justificativa e resolva os achados da revisão independente.
@@ -15,8 +15,10 @@ Use `verificar-trabalho` para provar as mudanças. Alterações web exigem testa
15
15
  o fluxo real no navegador; desktop exige abrir e testar a aplicação. Código
16
16
  revisado, build ou fixture isolada não substituem essa prova funcional.
17
17
 
18
- Comece arquivos Markdown com frontmatter YAML adequado ao tipo e título H1;
19
- preserve os metadados exigidos pela ferramenta.
18
+ Comece novos arquivos Markdown com frontmatter YAML adequado ao tipo e título
19
+ H1; preserve os metadados exigidos pela ferramenta. Em documentos humanos
20
+ existentes, mantenha o formato e o conteúdo: não os reformate apenas para
21
+ impor esse padrão.
20
22
 
21
23
  Ao criar ou atualizar `.gitignore`, mantenha o bloco do
22
24
  [modelo de gitignore](../../templates/gitignore/TEMPLATE.md)
@@ -59,9 +59,9 @@ recursos, com frontmatter YAML sem corpo. Caminhos são relativos à raiz.
59
59
  Somente os quatro arquivos principais são fontes; não registre documentos.
60
60
  Use `gitignore|pasta` para gerar apenas o `.gitignore`, como em `.vscode`.
61
61
 
62
- `sincronizar-agentes --force` reconstrói os destinos e limpa auxiliares,
63
- preservando configurações e workflows fora do escopo gerenciado. Conteúdo
64
- próprio substituído recebe backup em `.agent-tests/`; `--check` não altera nada.
62
+ `sincronizar-agentes --force` repara os destinos gerenciados com backup em
63
+ `.agent-tests/`. Recursos e configurações humanos permanecem intactos, inclusive
64
+ quando ocupam o caminho de um apontamento. `--check` não altera arquivos.
65
65
  O sincronizador também gera o catálogo completo e os índices auxiliares a partir
66
66
  das fontes. Helpers e testes constam como suporte, sem fingir comandos próprios.
67
67
  Ele protege os derivados contra versionamento e preserva regras humanas.
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: Inventário somente leitura da estrutura e documentação do projeto.
3
+ ---
4
+
5
+ # Inspecionar projeto
6
+
7
+ ```sh
8
+ node .pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs
9
+ ```
10
+
11
+ O JSON identifica tecnologia, comandos, estrutura e documentos existentes.
12
+ `documentation.files` mantém caminhos e caixa, papel provável, tamanho e hash
13
+ quando a leitura couber nos limites. Referências válidas do estado aparecem em
14
+ `configured`; nomes sugerem papéis, mas não comprovam conteúdo ou aprovação.
15
+
16
+ Links simbólicos são listados como opacos, sem ler seus destinos. Conteúdo dos
17
+ documentos e valores de configuração não aparecem no relatório. `scan` e
18
+ `warnings` informam limites e leitura incompleta; `scan.complete` resume essas
19
+ condições. Uma pasta simbólica como `docs` também aparece, sem presumir que
20
+ esteja vazia. Links não classificados recebem `role: unclassified`; enquanto
21
+ existir uma fronteira não inspecionada, `scan.complete` será falso. Não use
22
+ inventário incompleto como prova de ausência de documentos.
23
+
24
+ `exclusions` descreve pastas de clientes, artefatos e caches fora do inventário.
25
+ Documentos humanos em `.github`, exemplos, wiki e RFCs são incluídos; os
26
+ apontamentos gerados em `.github/skills`, agentes e comandos ficam de fora.
27
+
28
+ O script não cria nem altera arquivos. Leia os documentos relevantes antes de
29
+ planejar complementos; preserve sua estrutura e use templates só para lacunas.
30
+ `--check` confere a estrutura técnica. `--root` aceita somente as fixtures
31
+ documentadas pelo próprio comando, sem ampliar o acesso a outros projetos.
@@ -2,6 +2,7 @@
2
2
  // Inventario somente leitura; nomes e caminhos, nunca valores de configuracao.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { requireNode, isMain } from '../lib/runtime.mjs';
7
8
 
@@ -92,7 +93,146 @@ function maturity(root, report) {
92
93
  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.'] };
93
94
  }
94
95
 
95
- export function inspectProject(root = repository, { check = false } = {}) {
96
+ const documentRoles = new Set(['readme', 'contribuicao', 'changelog', 'seguranca', 'adrs', 'prd', 'ux_ui', 'design', 'roadmap', 'releases', 'specs', 'tarefas']);
97
+ const documentLimits = { maxDepth: 12, maxEntries: 25000, maxFiles: 2000, maxFileBytes: 2 * 1024 * 1024, maxTotalBytes: 16 * 1024 * 1024 };
98
+ const documentClients = [...defaultAgents, '.zcode', '.zai', '.kimi', '.kimi-code', '.minimax', '.mimocode', '.dsh'];
99
+ const documentName = name => /\.(?:md|mdx|rst|adoc)$/i.test(name) || /^(?:readme|contributing|changelog|history|security|licen[cs]e|copying|notice|authors|code_of_conduct|architecture|design|roadmap)(?:\.txt)?$/i.test(name);
100
+ const documentRole = file => {
101
+ const name = path.posix.basename(file).toLowerCase();
102
+ if (/^readme(?:\.|$)/.test(name)) return 'readme';
103
+ if (/^(?:agents|claude|gemini|copilot-instructions)\.md$/.test(name)) return 'instructions';
104
+ if (/^(?:licen[cs]e|copying|notice)(?:\.|$)/.test(name)) return 'license';
105
+ if (/^code_of_conduct(?:\.|$)/.test(name)) return 'conduct';
106
+ if (/^contribut/.test(name)) return 'contribution';
107
+ if (/^security(?:\.|$)/.test(name)) return 'security';
108
+ if (/^(?:changelog|history)(?:\.|$)/.test(name)) return 'changelog';
109
+ if (/(^|\/)(?:adr|adrs|decisions|rfcs)(\/|$)/i.test(file) || /^adr[-_.]/.test(name)) return 'decision';
110
+ if (/(^|\/)(?:prd|product|requisitos)(?:\.|\/|$)/i.test(file)) return 'product';
111
+ if (/(?:architecture|arquitetura|design)(?:\.|\/|$)/i.test(file)) return 'design';
112
+ if (/roadmap/i.test(name)) return 'roadmap';
113
+ if (/(^|\/)(?:spec|specs|specifications)(\/|$)/i.test(file)) return 'spec';
114
+ return 'document';
115
+ };
116
+
117
+ // Names, kinds and bounded hashes only. A guessed role is never an approval.
118
+ export function inspectDocumentation(root, limits = {}) {
119
+ root = fs.realpathSync(path.resolve(root));
120
+ const bounds = { ...documentLimits, ...limits };
121
+ for (const [key, value] of Object.entries(bounds)) {
122
+ if (!(key in documentLimits) || !Number.isSafeInteger(value) || value < 0 || value > documentLimits[key]) throw new Error('Limite documental invalido.');
123
+ }
124
+ const ignored = new Set([...defaultExcluded, ...documentClients, ...agentRoots(root)].filter(name => !['examples', '.github'].includes(name)).map(name => name.toLowerCase()));
125
+ const excludedPaths = ['examples/generated', ...['skills', 'agents', 'commands', 'rules', 'instructions', 'prompts'].map(name => `.github/${name}`)];
126
+ const files = new Map();
127
+ const configured = [];
128
+ const warnings = [];
129
+ const scan = { ...bounds, entries: 0, bytesRead: 0, bytesHashed: 0, truncated: false };
130
+ const warn = (file, reason) => warnings.push({ file, reason });
131
+ const excludedDocument = file => file.split('/').some(name => ignored.has(name.toLowerCase()) || /^\.?playwright/i.test(name))
132
+ || excludedPaths.some(prefix => file.toLowerCase() === prefix || file.toLowerCase().startsWith(prefix + '/'));
133
+ const safeDocument = value => typeof value === 'string' && value.length > 0 && !path.isAbsolute(value) && !/[\\\x00-\x1f\x7f:]/.test(value)
134
+ && !value.split('/').some(part => !part || part === '.' || part === '..');
135
+ const locate = file => {
136
+ let current = root;
137
+ const parts = file.split('/');
138
+ for (const [index, part] of parts.entries()) {
139
+ current = path.join(current, part);
140
+ let item;
141
+ try { item = fs.lstatSync(current); }
142
+ catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return { kind: 'missing' }; throw error; }
143
+ if (item.isSymbolicLink()) return { kind: 'symbolic-link', boundary: parts.slice(0, index + 1).join('/'), item };
144
+ if (index < parts.length - 1 && !item.isDirectory()) return { kind: 'missing' };
145
+ if (index === parts.length - 1) return { kind: item.isFile() ? 'file' : item.isDirectory() ? 'directory' : 'unsupported', item };
146
+ }
147
+ };
148
+ function boundedHash(file, expected) {
149
+ if (expected.size > bounds.maxFileBytes || expected.size > bounds.maxTotalBytes - scan.bytesRead) {
150
+ scan.truncated = true; warn(file, 'hash-byte-limit'); return;
151
+ }
152
+ let descriptor;
153
+ try {
154
+ descriptor = fs.openSync(path.join(root, file), fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
155
+ const before = fs.fstatSync(descriptor);
156
+ if (!before.isFile() || before.dev !== expected.dev || before.ino !== expected.ino || before.size !== expected.size
157
+ || before.mtimeMs !== expected.mtimeMs || before.ctimeMs !== expected.ctimeMs) { warn(file, 'changed-during-read'); return; }
158
+ const buffer = Buffer.alloc(Math.min(before.size + 1, bounds.maxTotalBytes - scan.bytesRead));
159
+ let length = 0;
160
+ while (length < buffer.length) {
161
+ const read = fs.readSync(descriptor, buffer, length, buffer.length - length, length);
162
+ if (!read) break;
163
+ length += read;
164
+ scan.bytesRead += read;
165
+ }
166
+ const after = fs.fstatSync(descriptor);
167
+ if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
168
+ warn(file, 'changed-during-read'); return;
169
+ }
170
+ scan.bytesHashed += length;
171
+ return createHash('sha256').update(buffer.subarray(0, length)).digest('hex');
172
+ } catch (error) { warn(file, error.code === 'ELOOP' ? 'symbolic-link-during-read' : 'unreadable-document'); }
173
+ finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
174
+ }
175
+ function add(file, found, role) {
176
+ if (files.has(file)) {
177
+ if (role) { const entry = files.get(file); entry.origin = 'configured'; entry.configuredAs = [...new Set([...(entry.configuredAs || []), role])].sort(); }
178
+ return;
179
+ }
180
+ if (files.size >= bounds.maxFiles) { scan.truncated = true; warn(file, 'file-limit'); return; }
181
+ const inferredRole = found.kind === 'symbolic-link' && !documentName(path.posix.basename(file)) ? 'unclassified' : documentRole(file);
182
+ const entry = { path: file, kind: found.kind, role: inferredRole, origin: role ? 'configured' : 'inferred', bytes: found.item.size };
183
+ if (role) entry.configuredAs = [role];
184
+ if (found.kind === 'symbolic-link') warn(file, 'symbolic-link-not-inspected');
185
+ if (found.kind === 'file') {
186
+ const digest = boundedHash(file, found.item);
187
+ if (digest) entry.sha256 = digest;
188
+ }
189
+ files.set(file, entry);
190
+ }
191
+ function walk(directory = '', depth = 0, role) {
192
+ if (directory) {
193
+ const found = locate(directory);
194
+ if (found.kind !== 'directory') { if (found.kind === 'symbolic-link') add(found.boundary, found, role); return; }
195
+ }
196
+ let children;
197
+ try { children = fs.readdirSync(path.join(root, directory), { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); }
198
+ catch { warn(directory || '.', 'unreadable-directory'); return; }
199
+ // Discard a listing if the directory became a link while being inspected.
200
+ if (directory && locate(directory).kind !== 'directory') { warn(directory, 'changed-during-read'); return; }
201
+ for (const child of children) {
202
+ if (scan.entries >= bounds.maxEntries || files.size >= bounds.maxFiles) { scan.truncated = true; return; }
203
+ scan.entries++;
204
+ const file = directory ? `${directory}/${child.name}` : child.name;
205
+ if (excludedDocument(file)) continue;
206
+ const found = locate(file);
207
+ if (found.kind === 'directory') {
208
+ if (depth < bounds.maxDepth) walk(file, depth + 1, role);
209
+ else { scan.truncated = true; warn(file, 'depth-limit'); }
210
+ } else if (found.kind === 'file' && documentName(child.name)) add(file, found, role);
211
+ else if (found.kind === 'symbolic-link') add(found.boundary, found, role);
212
+ }
213
+ }
214
+ const declared = metadata(root, '.pbai4devs/estado/configuracao/ESTADO.md').documents;
215
+ for (const [role, file] of Object.entries(declared)) {
216
+ if (!documentRoles.has(role)) continue;
217
+ if (!safeDocument(file)) { if (file !== null) warn(null, 'invalid-configured-path'); continue; }
218
+ if (excludedDocument(file)) { configured.push({ role, path: file, kind: 'excluded' }); continue; }
219
+ const found = locate(file);
220
+ configured.push({ role, path: file, kind: found.kind });
221
+ if (found.kind === 'directory') walk(file, 0, role);
222
+ else if (found.kind === 'file' && documentName(path.posix.basename(file))) add(file, found, role);
223
+ else if (found.kind === 'file') warn(file, 'configured-path-not-document');
224
+ else if (found.kind === 'symbolic-link') add(found.boundary, found, role);
225
+ }
226
+ walk();
227
+ return {
228
+ files: [...files.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
229
+ configured, scan: { ...scan, complete: !scan.truncated && warnings.length === 0 }, warnings,
230
+ exclusions: { directories: [...ignored].sort(), paths: excludedPaths, prefixes: ['playwright', '.playwright'] },
231
+ limits: ['Papeis inferidos por nome; confirme o conteudo antes de propor alteracoes.', 'Links simbolicos sao opacos; destinos nao sao lidos.', 'O inventario nao cria documentos nem comprova aprovacao ou completude.'],
232
+ };
233
+ }
234
+
235
+ export function inspectProject(root = repository, { check = false, documentationLimits } = {}) {
96
236
  requireNode();
97
237
  root = fs.realpathSync(path.resolve(root));
98
238
  if (!fs.statSync(root).isDirectory()) throw new Error('A origem deve ser um diretorio.');
@@ -160,6 +300,7 @@ export function inspectProject(root = repository, { check = false } = {}) {
160
300
  report.languages = [...languages].sort();
161
301
  report.contract = { checked: check, complete: report.structure.missing.length === 0 };
162
302
  report.maturity = maturity(root, report);
303
+ report.documentation = inspectDocumentation(root, documentationLimits);
163
304
  return report;
164
305
  }
165
306
 
@@ -1,9 +1,11 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import test from 'node:test';
6
7
  import { projectRoot, requireNode } from '../../lib/runtime.mjs';
8
+ import { inspectDocumentation } from '../SCRIPT.mjs';
7
9
 
8
10
  requireNode();
9
11
  const script = join(projectRoot, '.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs');
@@ -159,3 +161,168 @@ test('status arbitrários e referências fora da raiz não expõem dados da conf
159
161
  assert.equal(result.report.maturity.planning.ready, false);
160
162
  assert.ok(!result.stdout.includes(secret));
161
163
  });
164
+
165
+ test('inventário documental vazio não cria arquivos nem presume aprovação', t => {
166
+ const { root, run } = fixture(t);
167
+ const result = run();
168
+ assert.equal(result.status, 0, result.stderr);
169
+ assert.deepEqual(result.report.documentation.files, []);
170
+ assert.deepEqual(result.report.documentation.configured, []);
171
+ assert.equal(result.report.documentation.scan.truncated, false);
172
+ assert.equal(result.report.documentation.scan.complete, true);
173
+ assert.deepEqual(readdirSync(root), []);
174
+ assert.match(result.report.documentation.limits.at(-1), /nao cria documentos/);
175
+ });
176
+
177
+ test('descobre documentação real sem impor caixa, pasta docs ou frontmatter', t => {
178
+ const { root, file, run } = fixture(t);
179
+ const content = 'DOC_CONTENT_MUST_NOT_BE_EMITTED_182711';
180
+ for (const name of ['Readme.md', 'CONTRIBUTING.rst', 'SECURITY.adoc', 'History.md', 'ARCHITECTURE.md', 'AGENTS.md', 'LICENSE', 'examples/README', '_examples/server/README.md', 'wiki/guide.mdx', 'rfcs/001-change.md', 'packages/docs/content/api.mdx', '.github/CONTRIBUTING.md', '.github/SECURITY.md', '.github/copilot-instructions.md']) file(name, content);
181
+ for (const name of ['node_modules/lib/README.md', '.pbai4devs/README.md', '.claude/skills/private/SKILL.md', '.github/skills/demo/SKILL.md', '.github/agents/revisor.agent.md', 'examples/generated/fixture/README.md', '.PLAYWRIGHT-MCP/README.md', 'tmp/README.md']) file(name, content);
182
+ const result = run();
183
+ assert.equal(result.status, 0, result.stderr);
184
+ const docs = result.report.documentation;
185
+ assert.equal(docs.files.length, 15);
186
+ assert.equal(docs.files.find(item => item.path === 'Readme.md').role, 'readme');
187
+ assert.equal(docs.files.find(item => item.path === 'rfcs/001-change.md').role, 'decision');
188
+ assert.equal(docs.files.find(item => item.path === 'ARCHITECTURE.md').role, 'design');
189
+ assert.equal(docs.files.find(item => item.path === 'AGENTS.md').role, 'instructions');
190
+ assert.equal(docs.files.find(item => item.path === '.github/copilot-instructions.md').role, 'instructions');
191
+ assert.equal(docs.files.find(item => item.path === '.github/CONTRIBUTING.md').role, 'contribution');
192
+ assert.equal(docs.files.find(item => item.path === 'LICENSE').role, 'license');
193
+ assert.ok(docs.exclusions.paths.includes('.github/skills'));
194
+ assert.equal(docs.files.find(item => item.path === 'wiki/guide.mdx').sha256, createHash('sha256').update(content).digest('hex'));
195
+ assert.ok(docs.files.every(item => item.origin === 'inferred' && item.kind === 'file'));
196
+ assert.ok(!result.stdout.includes(content));
197
+ assert.equal(readFileSync(join(root, 'Readme.md'), 'utf8'), content);
198
+ });
199
+
200
+ test('caminhos cadastrados têm proveniência sem assumir status ou ler arquivos de configuração', t => {
201
+ const { file, run } = fixture(t);
202
+ file('.pbai4devs/estado/configuracao/ESTADO.md', '---\ndocumentos:\n design: architecture/current.md\n specs: specifications\n prd: planning/absent.md\n readme: ../private-secret.md\n seguranca: .env\n---\n# Estado\n');
203
+ file('architecture/current.md', '# Existing architecture\n');
204
+ file('specifications/current.mdx', '# Increment\n');
205
+ file('.env', 'DO_NOT_EMIT_OR_HASH_THIS_SECRET');
206
+ const result = run();
207
+ assert.equal(result.status, 0, result.stderr);
208
+ const docs = result.report.documentation;
209
+ assert.deepEqual(docs.files.find(item => item.path === 'architecture/current.md').configuredAs, ['design']);
210
+ assert.equal(docs.files.find(item => item.path === 'specifications/current.mdx').origin, 'configured');
211
+ assert.ok(docs.configured.some(item => item.role === 'prd' && item.kind === 'missing'));
212
+ assert.ok(docs.warnings.some(item => item.reason === 'invalid-configured-path'));
213
+ assert.ok(docs.warnings.some(item => item.reason === 'configured-path-not-document'));
214
+ assert.ok(!docs.files.some(item => item.path === '.env'));
215
+ assert.ok(!result.stdout.includes('private-secret') && !result.stdout.includes('DO_NOT_EMIT'));
216
+ assert.equal(docs.files.some(item => Object.hasOwn(item, 'status')), false);
217
+ });
218
+
219
+ test('documentos simbólicos permanecem opacos sem ler destinos internos, externos ou ausentes', t => {
220
+ const { root, file, run } = fixture(t);
221
+ const outside = mkdtempSync(join(projectRoot, '.agent-tests', 'outside-docs-'));
222
+ t.after(() => rmSync(outside, { recursive: true, force: true }));
223
+ file('docs/real.md', '# Actual document\n');
224
+ writeFileSync(join(outside, 'private.md'), 'OUTSIDE_SECRET_MUST_NOT_APPEAR');
225
+ try {
226
+ symlinkSync('docs/real.md', join(root, 'README.md'));
227
+ symlinkSync(join(outside, 'private.md'), join(root, 'CLAUDE.md'));
228
+ symlinkSync('absent.md', join(root, 'missing.md'));
229
+ symlinkSync(outside, join(root, 'external'), process.platform === 'win32' ? 'junction' : 'dir');
230
+ } catch (error) {
231
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code)) throw error;
232
+ t.skip('Links simbólicos indisponíveis sem permissão do Windows.'); return;
233
+ }
234
+ file('.pbai4devs/estado/configuracao/ESTADO.md', '---\ndocumentos:\n design: external/private.md\n---\n# State\n');
235
+ const result = run();
236
+ assert.equal(result.status, 0, result.stderr);
237
+ const docs = result.report.documentation;
238
+ for (const name of ['README.md', 'CLAUDE.md', 'missing.md', 'external']) {
239
+ const item = docs.files.find(item => item.path === name);
240
+ assert.equal(item?.kind, 'symbolic-link', name);
241
+ assert.equal(item.sha256, undefined);
242
+ }
243
+ assert.ok(!docs.files.some(item => item.path === 'external/private.md'));
244
+ assert.ok(!result.stdout.includes(outside) && !result.stdout.includes('OUTSIDE_SECRET'));
245
+ assert.equal(docs.scan.complete, false);
246
+ assert.ok(docs.warnings.some(item => item.file === 'external' && item.reason === 'symbolic-link-not-inspected'));
247
+ assert.equal(readFileSync(join(outside, 'private.md'), 'utf8'), 'OUTSIDE_SECRET_MUST_NOT_APPEAR');
248
+ });
249
+
250
+ test('pastas documentais simbólicas sem cadastro não ocultam uma leitura incompleta', t => {
251
+ const { root, run } = fixture(t);
252
+ const outside = mkdtempSync(join(projectRoot, '.agent-tests', 'outside-doc-tree-'));
253
+ t.after(() => rmSync(outside, { recursive: true, force: true }));
254
+ const privateContent = 'UNREAD_EXTERNAL_DOCUMENT_719329';
255
+ writeFileSync(join(outside, 'private.md'), privateContent);
256
+ try {
257
+ for (const name of ['docs', 'wiki', 'rfcs', 'examples', 'custom-docs']) symlinkSync(outside, join(root, name), process.platform === 'win32' ? 'junction' : 'dir');
258
+ symlinkSync('absent-directory', join(root, 'missing-directory'), process.platform === 'win32' ? 'junction' : 'dir');
259
+ } catch (error) {
260
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code)) throw error;
261
+ t.skip('Links simbólicos indisponíveis sem permissão do Windows.'); return;
262
+ }
263
+ const result = run();
264
+ assert.equal(result.status, 0, result.stderr);
265
+ const docs = result.report.documentation;
266
+ assert.equal(docs.scan.complete, false);
267
+ assert.equal(docs.scan.bytesRead, 0);
268
+ assert.deepEqual(docs.configured, []);
269
+ assert.deepEqual(docs.files.map(item => item.path), ['custom-docs', 'docs', 'examples', 'missing-directory', 'rfcs', 'wiki']);
270
+ assert.ok(docs.files.every(item => item.kind === 'symbolic-link' && item.role === 'unclassified' && !item.sha256));
271
+ assert.equal(docs.warnings.filter(item => item.reason === 'symbolic-link-not-inspected').length, 6);
272
+ assert.ok(!result.stdout.includes(outside) && !result.stdout.includes(privateContent));
273
+ assert.equal(readFileSync(join(outside, 'private.md'), 'utf8'), privateContent);
274
+ const limited = inspectDocumentation(root, { maxFiles: 1 });
275
+ assert.equal(limited.files.length, 1);
276
+ assert.equal(limited.scan.truncated, true);
277
+ assert.equal(limited.scan.complete, false);
278
+ });
279
+
280
+ test('limites documentais são explícitos e não retornam inventário parcial como completo', t => {
281
+ const { root, file } = fixture(t);
282
+ file('a.md', '123'); file('b.md', '456'); file('deep/nested/c.md', '789');
283
+ const files = inspectDocumentation(root, { maxFiles: 1 });
284
+ assert.equal(files.files.length, 1); assert.equal(files.scan.truncated, true);
285
+ assert.equal(files.scan.complete, false);
286
+ const entries = inspectDocumentation(root, { maxEntries: 1 });
287
+ assert.equal(entries.scan.entries, 1); assert.equal(entries.scan.truncated, true);
288
+ const depth = inspectDocumentation(root, { maxDepth: 0 });
289
+ assert.ok(!depth.files.some(item => item.path.startsWith('deep/')));
290
+ assert.ok(depth.warnings.some(item => item.reason === 'depth-limit'));
291
+ const bytes = inspectDocumentation(root, { maxFileBytes: 2 });
292
+ assert.equal(bytes.scan.bytesHashed, 0); assert.equal(bytes.scan.truncated, true);
293
+ assert.ok(bytes.files.every(item => !item.sha256));
294
+ const total = inspectDocumentation(root, { maxTotalBytes: 4 });
295
+ assert.ok(total.scan.bytesRead <= 4); assert.ok(total.scan.bytesHashed <= 4);
296
+ assert.equal(total.scan.truncated, true);
297
+ for (const limits of [{ maxFiles: -1 }, { maxEntries: 25001 }, { unknown: 1 }]) assert.throws(() => inspectDocumentation(root, limits), /Limite documental/);
298
+ });
299
+
300
+ test('inventário repetido preserva bytes, modos, caminhos e índice Git', t => {
301
+ const { root, file, run } = fixture(t);
302
+ file('README.md', '# Existing\n'); file('docs/guide.md', 'No imposed frontmatter\n');
303
+ const git = (...args) => {
304
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
305
+ assert.equal(result.status, 0, result.stderr); return result.stdout;
306
+ };
307
+ git('init', '--quiet', '--template='); git('add', '--', 'README.md');
308
+ const snapshot = () => {
309
+ const files = {};
310
+ const visit = (folder = '') => {
311
+ for (const name of readdirSync(join(root, folder)).sort()) {
312
+ if (name === '.git') continue;
313
+ const relative = folder ? `${folder}/${name}` : name;
314
+ const info = lstatSync(join(root, relative));
315
+ files[relative] = { mode: info.mode, kind: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symbolic-link' : 'file' };
316
+ if (info.isSymbolicLink()) files[relative].target = readlinkSync(join(root, relative));
317
+ else if (info.isDirectory()) visit(relative);
318
+ else files[relative].sha256 = createHash('sha256').update(readFileSync(join(root, relative))).digest('hex');
319
+ }
320
+ };
321
+ visit(); return { files, index: git('ls-files', '--stage'), staged: git('diff', '--cached', '--binary'), config: readFileSync(join(root, '.git/config'), 'utf8') };
322
+ };
323
+ const before = snapshot();
324
+ const first = run(), second = run();
325
+ assert.equal(first.status, 0, first.stderr); assert.equal(second.status, 0, second.stderr);
326
+ assert.deepEqual(first.report.documentation, second.report.documentation);
327
+ assert.deepEqual(snapshot(), before);
328
+ });
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
2
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
@@ -63,6 +63,7 @@ export function renderClientIgnore(template, settings = {}) {
63
63
 
64
64
  const localStat = file => { try { return lstatSync(file); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; } };
65
65
  const localRead = file => localStat(file) ? readFileSync(file, 'utf8') : null;
66
+ const localMode = file => { const item = localStat(file); return item ? item.mode & 0o777 : null; };
66
67
  const portable = path => path.split(sep).join('/');
67
68
 
68
69
  function canonicalExisting(path) {
@@ -144,6 +145,61 @@ function artifactProbes(root, patterns) {
144
145
  return [...probes];
145
146
  }
146
147
 
148
+ const matchesComponent = (part, pattern) => posix.matchesGlob(`x${part}`, `x${pattern.replace(/[{}()]/g, '\\$&')}`);
149
+
150
+ function matchesParts(parts, pattern) {
151
+ const cache = new Map();
152
+ function match(part, glob) {
153
+ const key = `${part}:${glob}`; if (cache.has(key)) return cache.get(key);
154
+ const expected = pattern[glob];
155
+ const result = glob === pattern.length ? part === parts.length
156
+ : expected === '**' ? match(part, glob + 1) || part < parts.length && match(part + 1, glob)
157
+ : part < parts.length && matchesComponent(parts[part], expected) && match(part + 1, glob + 1);
158
+ cache.set(key, result); return result;
159
+ }
160
+ return match(0, 0);
161
+ }
162
+
163
+ function artifactPath(path, patterns, directory = false) {
164
+ const parts = path.split('/');
165
+ return patterns.some(pattern => {
166
+ const rule = pattern.slice(3).replace(/\/$/, '');
167
+ return parts.some((part, index) => (index < parts.length - 1 || directory || !pattern.endsWith('/'))
168
+ && (rule.includes('/') ? matchesParts(parts.slice(0, index + 1), ['**', ...rule.split('/')])
169
+ : matchesComponent(part, rule)));
170
+ });
171
+ }
172
+
173
+ function visibleArtifactProbes(root, location, patterns) {
174
+ const listed = gitCommand(location.top, ['--literal-pathspecs', '-c', 'core.excludesFile=', 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', location.prefix ? `${location.prefix}/` : '.']);
175
+ if (listed.error || listed.status !== 0) throw new Error(listed.error?.message || listed.stderr || 'Falha ao conferir artefatos existentes.');
176
+ const paths = listed.stdout.split('\0').filter(Boolean), prefix = location.prefix ? `${location.prefix}/` : '';
177
+ const probes = new Set(paths.filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length)).filter(path => artifactPath(path, patterns)));
178
+ const ignores = new Set(paths.filter(path => path.startsWith(prefix) && (path === '.gitignore' || path.endsWith('/.gitignore'))));
179
+ let ancestor = root;
180
+ while (true) {
181
+ ignores.add(portable(relative(location.top, join(ancestor, '.gitignore'))));
182
+ if (ancestor === location.top) break;
183
+ ancestor = dirname(ancestor);
184
+ }
185
+ for (const path of ignores) {
186
+ const file = join(location.top, path), item = localStat(file);
187
+ if (!item?.isFile() || item.isSymbolicLink()) continue;
188
+ // Git resolves precedence; these literals only add witnesses for named exceptions.
189
+ for (const line of readFileSync(checkedLocalPath(file), 'utf8').split(/\r?\n/)) {
190
+ if (!line.startsWith('!') || line.length === 1) continue;
191
+ const rule = line.slice(1).replace(/(?<!\\) +$/, ''), directory = rule.endsWith('/') || rule.endsWith('/**');
192
+ const literal = rule.replace(/(?:\/\*\*)+\/?$/, '').replace(/\/$/, '').replace(/^\//, '');
193
+ if (/(^|[^\\])[*?\[]/.test(literal)) continue;
194
+ const ruleBase = !rule.startsWith('/') && !literal.includes('/') && relative(dirname(file), root).split(sep)[0] !== '..' ? root : dirname(file);
195
+ const candidate = portable(relative(root, resolve(ruleBase, literal.replace(/\\(.)/g, '$1'))));
196
+ if (!candidate || candidate === '..' || candidate.startsWith('../') || !artifactPath(candidate, patterns, directory)) continue;
197
+ probes.add(checkedOutput(directory ? `${candidate}/.agentdevkit-artifact-probe` : candidate));
198
+ }
199
+ }
200
+ return [...probes];
201
+ }
202
+
147
203
  function localIgnoreSource(location, source) {
148
204
  const path = resolve(location.top, source);
149
205
  if (path === location.exclude) return true;
@@ -204,9 +260,9 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
204
260
  const body = fixed.split(/\r?\n/).map(line => location?.prefix && line.startsWith('/') ? `/${escapePattern(location.prefix)}${line}` : line).join('\n');
205
261
  const target = location?.exclude ?? checkedLocalPath(join(root, '.gitignore'));
206
262
  const before = localRead(target), after = localBlock(before, first, last, body);
207
- const edits = before === after ? [] : [{ path: target, before, after }];
263
+ const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
208
264
  const probes = [...paths, ...rootProbes(root, roots)];
209
- const artifactPaths = artifactProbes(root, artifacts);
265
+ const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...(location ? visibleArtifactProbes(root, location, artifacts) : [])])];
210
266
  const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
211
267
  if (location && !before?.includes(first) && !localRead(join(root, '.gitignore'))?.includes(first)) {
212
268
  const ignored = ignoreMatches(location, [...probes, ...artifactPaths]);
@@ -215,10 +271,10 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
215
271
  if (location) {
216
272
  const fallback = checkedLocalPath(join(root, '.gitignore'));
217
273
  const old = localRead(fallback), next = localBlock(old, first, last, null);
218
- if (old !== null && old !== next) edits.push({ path: fallback, before: old, after: next });
274
+ if (old !== null && old !== next) edits.push({ path: fallback, before: old, after: next, beforeMode: localMode(fallback) });
219
275
  }
220
276
  const tracked = location ? gitCommand(location.top, ['ls-files', '-z']).stdout.split('\0').filter(path => wanted.has(path)).map(path => location.prefix ? path.slice(location.prefix.length + 1) : path) : [];
221
- return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
277
+ return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactPatterns: artifacts, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
222
278
  }
223
279
 
224
280
  export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
@@ -235,7 +291,8 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
235
291
  return { ...item, path };
236
292
  });
237
293
  if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
238
- const probes = [...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || [])];
294
+ const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || []),
295
+ ...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : [])])];
239
296
  const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
240
297
  for (let index = 0; index < relativePaths.length; index++) {
241
298
  const match = matches.get(relativePaths[index]);
@@ -252,10 +309,21 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
252
309
  export function assertLocalIgnoreUnchanged(plan) {
253
310
  for (const edit of plan.edits) {
254
311
  checkedLocalPath(edit.path);
255
- if (localRead(edit.path) !== edit.before) throw new Error(`Exclusao alterada apos planejar: ${edit.path}`);
312
+ if (localRead(edit.path) !== edit.before || Object.hasOwn(edit, 'beforeMode') && localMode(edit.path) !== edit.beforeMode) throw new Error(`Exclusao alterada apos planejar: ${edit.path}`);
256
313
  }
257
314
  }
258
315
 
316
+ function atomicLocalWrite(path, content, mode, published) {
317
+ const temporary = `${path}.agentdevkit-${randomUUID()}`;
318
+ try {
319
+ writeFileSync(temporary, content, { flag: 'wx' });
320
+ if (mode !== null && mode !== undefined) chmodSync(temporary, mode);
321
+ const writtenMode = localMode(temporary);
322
+ renameSync(temporary, path);
323
+ published?.(writtenMode);
324
+ } finally { rmSync(temporary, { force: true }); }
325
+ }
326
+
259
327
  export function applyLocalIgnore(plan) {
260
328
  assertLocalIgnoreUnchanged(plan);
261
329
  const before = checkLocalIgnore(plan);
@@ -264,18 +332,19 @@ export function applyLocalIgnore(plan) {
264
332
  try {
265
333
  for (const edit of plan.edits) {
266
334
  mkdirSync(dirname(edit.path), { recursive: true });
267
- const temporary = `${edit.path}.agentdevkit-${randomUUID()}`;
268
- try { writeFileSync(temporary, edit.after, { flag: 'wx' }); renameSync(temporary, edit.path); }
269
- finally { rmSync(temporary, { force: true }); }
270
- written.push(edit);
335
+ atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }));
271
336
  }
272
337
  const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
273
338
  if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
274
339
  } catch (error) {
275
- for (const edit of written.reverse()) {
276
- if (localRead(edit.path) !== edit.after) continue;
277
- if (edit.before === null) rmSync(edit.path, { force: true }); else writeFileSync(edit.path, edit.before);
340
+ const rollbackErrors = [];
341
+ for (const { edit, mode } of written.reverse()) {
342
+ try {
343
+ if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) continue;
344
+ if (edit.before === null) rmSync(edit.path, { force: true }); else atomicLocalWrite(edit.path, edit.before, edit.beforeMode);
345
+ } catch (failure) { rollbackErrors.push(failure); }
278
346
  }
347
+ if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
279
348
  throw error;
280
349
  }
281
350
  return { status: plan.status, changed: plan.edits.length, trackedOutputs: plan.trackedOutputs };