@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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Requisitos da distribuição portátil AgentDevKit.
3
3
  status: aprovado
4
- version: 0.0.6
4
+ version: 0.0.9
5
5
  ---
6
6
 
7
7
  # PRD do AgentDevKit
@@ -40,6 +40,9 @@ extraído do projeto de origem como produto independente.
40
40
  | FR-018 | Publicar autoria PathBit, palavras-chave e licença MIT no npm. |
41
41
  | FR-019 | Exibir README legível no npm sem alterar o Markdown fonte. |
42
42
  | FR-020 | Levar a licença do kit ao hub sem alterar a licença do projeto. |
43
+ | FR-021 | Inventariar documentos existentes antes de propor alterações. |
44
+ | FR-022 | Complementar documentação sem substituir arquivos por templates. |
45
+ | FR-023 | Provar o fluxo documental em projetos reais e em projeto vazio. |
43
46
 
44
47
  Execuções repetidas devem ser idempotentes; a CLI oferece simulação e JSON.
45
48
  As instruções permanecem concisas, com detalhes opcionais em referências.
@@ -18,6 +18,8 @@ status: vigente
18
18
  | Instalação pelo registro v0.0.5 | Pacote publicado | [Spec][registry]. |
19
19
  | Apresentação e licença v0.0.6 | Distribuição e atualização | [Spec][public]. |
20
20
  | Compatibilidade real v0.0.7 | Testes em clones existentes | [Spec][oss]. |
21
+ | Preservação v0.0.8 | Auditoria e regressões | [Spec][robustness]. |
22
+ | Coexistência v0.0.9 | Agentes, documentos e projetos reais | [Spec][coexistence]. |
21
23
 
22
24
  A primeira entrega segue a [spec v0.0.1](../specs/v0.0.1/SPEC.md), disponível
23
25
  na [release v0.0.1][release]. Os detalhes de mudanças estão no
@@ -38,3 +40,5 @@ comportamento existente, sem alterar a versão de uma release já publicada.
38
40
  [registry]: ../specs/v0.0.5/SPEC.md
39
41
  [public]: ../specs/v0.0.6/SPEC.md
40
42
  [oss]: ../specs/v0.0.7/SPEC.md
43
+ [robustness]: ../specs/v0.0.8/SPEC.md
44
+ [coexistence]: ../specs/v0.0.9/SPEC.md
@@ -12,6 +12,8 @@ description: Incrementos verificáveis do AgentDevKit.
12
12
  - [v0.0.5](v0.0.5/SPEC.md): instalação pelo registro em todos os fluxos.
13
13
  - [v0.0.6](v0.0.6/SPEC.md): apresentação pública, licença e atualização segura.
14
14
  - [v0.0.7](v0.0.7/SPEC.md): projetos reais e proteção local de artefatos.
15
+ - [v0.0.8](v0.0.8/SPEC.md): preservação, transações e validação aprofundada.
16
+ - [v0.0.9](v0.0.9/SPEC.md): coexistência e evolução documental sem perdas.
15
17
 
16
18
  Cada incremento vincula requisitos, critérios, testes e evidências locais.
17
19
 
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Validação aprofundada e preservação de dados do AgentDevKit.
3
+ ---
4
+
5
+ # Versão 0.0.8
6
+
7
+ A [spec](SPEC.md) reúne correções da auditoria do instalador, sincronizador,
8
+ gerenciador de ferramentas, CLI e regras de ignore.
9
+
10
+ [Specs](../README.md)
@@ -0,0 +1,62 @@
1
+ ---
2
+ description: Correções verificadas de preservação, transações e interface CLI.
3
+ status: aprovado
4
+ version: 0.0.8
5
+ ---
6
+
7
+ # Preservação e validação aprofundada
8
+
9
+ ## Objetivo e autorização
10
+
11
+ Atender ao pedido de revisão profunda, corrigindo falhas comprovadas e
12
+ publicando uma nova versão estável após validação. Esta etapa continua o
13
+ [PRD](../../product/prd.md), o [design](../../product/design.md) e os
14
+ [testes em projetos reais](../v0.0.7/SPEC.md).
15
+
16
+ ## Contrato
17
+
18
+ - Não apagar ou sobrescrever arquivos humanos pela aparência de seu Markdown.
19
+ Exigir propriedade comprovada para limpeza automática de apontamentos.
20
+ - Recusar conflitos conhecidos antes de escrever. Em falhas posteriores,
21
+ restaurar as alterações próprias sem substituir edições humanas concorrentes.
22
+ - Preservar permissões em atualização, backup e rollback, inclusive em
23
+ arquivos somente para leitura quando a operação for permitida pelo sistema.
24
+ - Recusar componentes simbólicos nos caminhos locais de ferramentas, inclusive
25
+ após operações externas, antes de escrever ou confirmar uma instalação.
26
+ - Preservar integralmente argumentos após `tools ... --`; ajuda e versão
27
+ não exigem diretório de projeto válido.
28
+ - Conferir artefatos existentes e exceções literais além dos nomes de amostra,
29
+ sem ler ignores de projetos irmãos ou desindexar arquivos humanos.
30
+ - Manter a recusa de links simbólicos incompatíveis do Zod; não converter
31
+ esse bloqueio documentado em uma instalação bem-sucedida.
32
+
33
+ ## Critérios de aceite
34
+
35
+ | ID | Resultado observável |
36
+ | --- | --- |
37
+ | AC-001 | Markdown humano é preservado; limpeza usa propriedade comprovada. |
38
+ | AC-002 | Falhas não deixam gravações próprias parciais nem mudam permissões. |
39
+ | AC-003 | Gerenciador não escreve fora da instalação por links simbólicos. |
40
+ | AC-004 | CLI repassa argumentos; ajuda funciona sem projeto. |
41
+ | AC-005 | Ignore protege artefatos sem invadir projetos irmãos. |
42
+ | AC-006 | Regressões, pacote público e atualização do router são verificados. |
43
+
44
+ ## Verificação
45
+
46
+ Para AC-001 a AC-005, conservar reproduções anteriores e testes de regressão
47
+ que falham antes da correção, com assertivas de conteúdo, modo, estado e Git.
48
+ Executar os testes relevantes em Node 24/26 e revisar os diffs de forma
49
+ independente. Limitações de plataforma devem ficar explícitas.
50
+
51
+ Para AC-006, executar pipeline completo, cenários de maturidade e matriz OSS
52
+ do pacote gerado. Confirmar no CI Linux, macOS e Windows; publicar o mesmo TGZ,
53
+ comparar checksum e repetir o consumo público. Atualizar o router com preview,
54
+ backup, diagnóstico e comparação da baseline, preservando o índice existente.
55
+
56
+ Testes e fixtures são versionados. Clones e provas ficam em `tmp/` e o registro
57
+ de revisão em `.agent-tests/`. Não prometer ausência universal de bugs.
58
+
59
+ ## Documentação afetada
60
+
61
+ Atualizar referência da CLI, instalação, ferramentas, changelog, README,
62
+ roadmap e índices. Não criar documentos de produto nos projetos consumidores.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Preservação de agentes e evolução da documentação existente.
3
+ ---
4
+
5
+ # Versão 0.0.9
6
+
7
+ [Spec](SPEC.md): preservar agentes e complementar documentos existentes.
8
+
9
+ [Specs](../README.md) · [Changelog](../../../CHANGELOG.md)
@@ -0,0 +1,68 @@
1
+ ---
2
+ description: Coexistência com agentes e evolução da documentação existente.
3
+ status: aprovado
4
+ version: 0.0.9
5
+ ---
6
+
7
+ # Preservar agentes e documentação existentes
8
+
9
+ ## Objetivo
10
+
11
+ Instalar e atualizar o AgentDevKit no Zod e em projetos com agentes próprios,
12
+ preservando configurações, arquivos, permissões, links e estado do Git.
13
+ Orientar também a evolução documental a partir do inventário e da leitura
14
+ dos materiais existentes, sem reiniciar o projeto. O pedido atual substitui
15
+ o bloqueio conservador documentado na
16
+ [versão anterior](../v0.0.8/SPEC.md).
17
+
18
+ ## Contrato
19
+
20
+ - Tratar links simbólicos humanos nas entradas e nos clientes como caminhos
21
+ preservados: não abrir, atravessar, converter, sobrescrever ou adotar.
22
+ - Preservar agentes e skills humanos, inclusive nomes iguais aos do kit e
23
+ durante execução com `--force`. Informar os caminhos preservados.
24
+ - Gerar os apontamentos disponíveis sem atribuir ao kit arquivos preservados.
25
+ No Zod, manter `.codex/skills` e usar também `.agents/skills` para o Codex.
26
+ - Manter a validação estrita em fontes, estado, ferramentas, Git e caminhos
27
+ antes pertencentes ao kit. Um link não pode esconder alteração gerenciada.
28
+ - Manter os mesmos contratos na CLI e no sincronizador instalado.
29
+ - Antes de editar documentos, inventariar e ler os existentes. Reaproveitar
30
+ caminhos, seções e decisões; templates orientam somente lacunas.
31
+ - Organizar por índices e links, sem apagar, mover ou substituir documentos.
32
+ Em projetos novos, não inventar produto ou aprovação sem respostas.
33
+
34
+ ## Critérios de aceite
35
+
36
+ | ID | Resultado observável |
37
+ | --- | --- |
38
+ | AC-001 | Links humanos internos, externos e quebrados permanecem intactos. |
39
+ | AC-002 | Skills, agentes e configurações preexistentes são preservados. |
40
+ | AC-003 | Caminhos gerenciados recusam links sem escrita externa. |
41
+ | AC-004 | Init, update, doctor e sync são coerentes e idempotentes. |
42
+ | AC-005 | Zod, Express e Chi passam na matriz do pacote, preservando Git. |
43
+ | AC-006 | Regressões, documentação, CI e pacote público são verificados. |
44
+ | AC-007 | Inventário detecta documentação real sem alterar arquivos. |
45
+ | AC-008 | Skills exigem leitura e plano aditivo antes dos templates. |
46
+ | AC-009 | Exercícios documentais preservam conteúdo e arquivos fora do plano. |
47
+ | AC-010 | Projeto vazio recebe o kit sem documentos de produto inventados. |
48
+
49
+ ## Validação
50
+
51
+ Executar testes de regressão para links e colisões, inclusive `--force`,
52
+ substituição de caminhos após o plano e mudança de seleção dos clientes.
53
+ Rodar pipeline completo, maturidade e matriz OSS com o TGZ candidato;
54
+ repetir a matriz com o pacote publicado. Comparar bytes, tipos, modos,
55
+ destinos de links, staging e configuração Git nos clones.
56
+ Execute também o inventário instalado e o fluxo de documentação em clones
57
+ reais: plano por arquivo, complemento baseado em fatos, índice novo e revisão.
58
+ Separe essa prova da simples instalação e de fixtures do harness; não atribua
59
+ ao pacote geração automática de conteúdo ou aprovações de produto.
60
+
61
+ Clones e evidências ficam ignorados em `tmp/` e `.agent-tests/`.
62
+ O escopo é o kit e seu fluxo documental; não afirmar execução das aplicações
63
+ clonadas.
64
+
65
+ ## Documentação afetada
66
+
67
+ Atualizar instalação, CLI, recuperação, recursos, sincronização, testes,
68
+ changelog e índices. Preservar as specs antigas como histórico.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathbit/agentdevkit",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Shared skills, specifications and workflows for AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,7 +51,8 @@
51
51
  "ci:full": "node scripts/release/pipeline.mjs full",
52
52
  "ci:portable": "node scripts/release/pipeline.mjs portable",
53
53
  "ci:smoke": "node scripts/release/pipeline.mjs smoke",
54
- "test:oss": "node tests/oss-compatibility.mjs"
54
+ "test:oss": "node tests/oss-compatibility.mjs",
55
+ "test:docs": "node tests/documentation-compatibility.mjs"
55
56
  },
56
57
  "author": {
57
58
  "name": "PathBit",
package/src/cli.mjs CHANGED
@@ -18,7 +18,10 @@ export function parseArgs(argv) {
18
18
  const options = { command: null, positionals: [], toolArgs: [], target: null, language: null, clients: undefined, yes: false, json: false, dryRun: false, force: false, check: false };
19
19
  for (let index = 0; index < argv.length; index++) {
20
20
  const arg = argv[index];
21
- if (['--target', '--lang', '--agents', '--clients'].includes(arg)) {
21
+ if (arg === '--' && options.command === 'tools') {
22
+ options.toolArgs.push(...argv.slice(index));
23
+ break;
24
+ } else if (['--target', '--lang', '--agents', '--clients'].includes(arg)) {
22
25
  const value = argv[++index]; if (!value || value.startsWith('-')) usage(`Missing value: ${arg}`);
23
26
  if (arg === '--target') options.target = value;
24
27
  else if (arg === '--lang') { options.language = normalizeLanguage(value); if (!options.language) usage('Use --lang pt, en or es.'); }
@@ -59,6 +62,7 @@ function print(result, options, output) {
59
62
  if (result.clients) output.write(`${text(result.language, 'clientsCurrent')}: ${result.clients.join(', ')}\n`);
60
63
  if (result.availableClients) output.write(`${text(result.language, 'clientsAvailable')}: ${result.availableClients.map(client => client.id).join(', ')}\n`);
61
64
  if (result.toolOutput) output.write(`${result.toolOutput.trimEnd()}\n`);
65
+ if (result.preserved?.length) output.write(`${text(result.language, 'preserved')}: ${result.preserved.length}. ${text(result.language, 'preservedDetails')}\n`);
62
66
  if (result.plan?.length) {
63
67
  const names = { pt: { create: 'criar', update: 'atualizar', remove: 'remover' }, en: { create: 'create', update: 'update', remove: 'remove' }, es: { create: 'crear', update: 'actualizar', remove: 'eliminar' } };
64
68
  const labels = {
@@ -86,11 +90,14 @@ function print(result, options, output) {
86
90
 
87
91
  export async function main(argv = process.argv.slice(2), context = {}) {
88
92
  const output = context.stdout || process.stdout, input = context.stdin || process.stdin, errorOutput = context.stderr || process.stderr;
89
- let options = { json: argv.includes('--json') }, language = normalizeLanguage(argv[argv.indexOf('--lang') + 1]) || 'pt', target = resolve(context.cwd || process.cwd()), clients = ['all'];
93
+ const separator = argv.indexOf('--'), globalArgs = separator < 0 ? argv : argv.slice(0, separator);
94
+ let options = { json: globalArgs.includes('--json') }, language = normalizeLanguage(globalArgs[globalArgs.indexOf('--lang') + 1]) || 'pt', target = resolve(context.cwd || process.cwd()), clients = ['all'];
90
95
  try {
91
96
  options = parseArgs(argv);
92
- target = resolveTarget(options.target || (options.command === 'init' ? options.positionals[0] : '.'), context.cwd || process.cwd());
93
- const previous = ['help', 'version'].includes(options.command) ? null : loadState(target);
97
+ const informational = ['help', 'version'].includes(options.command);
98
+ target = informational ? resolve(context.cwd || process.cwd(), options.target || '.')
99
+ : resolveTarget(options.target || (options.command === 'init' ? options.positionals[0] : '.'), context.cwd || process.cwd());
100
+ const previous = informational ? null : loadState(target);
94
101
  language = options.language || previous?.language || 'pt';
95
102
  clients = options.clients ?? previous?.clients ?? ['all'];
96
103
  if (options.command === 'init' && !previous && !options.yes && !options.json && !options.dryRun && input.isTTY && output.isTTY && (!options.language || options.clients === undefined)) {
@@ -117,6 +124,7 @@ export async function main(argv = process.argv.slice(2), context = {}) {
117
124
  const found = inspect(target);
118
125
  if (found.installed && !found.issues.length) {
119
126
  const plan = planInstallation({ target, language, mode: 'sync' });
127
+ result.preserved = plan.preserved;
120
128
  found.issues.push(...plan.changes.map(change => ({ code: 'outdated', path: change.path })));
121
129
  found.issues.push(...plan.localIgnoreCheck.missing.map(path => ({ code: 'unignored', path })));
122
130
  if (plan.localIgnore.edits.length || plan.untrack?.paths.length) found.issues.push({ code: 'local-ignore', path: '.gitignore / Git info/exclude' });
@@ -138,6 +146,7 @@ export async function main(argv = process.argv.slice(2), context = {}) {
138
146
  result.next = getNextSteps({ command: options.command, language, target, installed: Boolean(previous) });
139
147
  }
140
148
  const plan = planInstallation({ target, language, mode: options.command, force: options.force, clients: options.clients });
149
+ result.preserved = plan.preserved;
141
150
  result.plan = plan.changes.map(({ path, action }) => ({ path, action, conflict: plan.conflicts.includes(path) }));
142
151
  result.untrackedPaths = [...(plan.untrack?.paths || [])];
143
152
  result.localIgnore = { status: plan.localIgnore.status, edits: plan.localIgnore.edits.map(({ path, before }) => ({ path, action: before === null ? 'create' : 'update' })), missing: plan.localIgnoreCheck.missing };
package/src/i18n.mjs CHANGED
@@ -7,6 +7,7 @@ export function normalizeLanguage(value) {
7
7
 
8
8
  const messages = {
9
9
  pt: {
10
+ preserved: 'Caminhos existentes preservados', preservedDetails: 'Use --json para consultar os caminhos e motivos.',
10
11
  help: 'AgentDevKit: init [diretório], update, doctor, sync [--check], lang [pt|en|es], agents [lista], next, tools <ação>.\nOpções: --target <diretório>, --lang <idioma>, --agents <lista>, --yes, --dry-run, --force, --json.\nAliases: --clients e clients.',
11
12
  clientsCurrent: 'Agentes selecionados', clientsAvailable: 'Agentes disponíveis', clientsSelect: 'Agentes (IDs separados por vírgula ou all) [all]: ', agents: 'Seleção de agentes atualizada.',
12
13
  next: 'Próximo passo', init: 'Estrutura instalada.', update: 'Estrutura atualizada.', sync: 'Apontamentos sincronizados.', lang: 'Idioma configurado.', unchanged: 'Estrutura já atualizada.', plan: 'Plano calculado; nenhum arquivo alterado.', conflict: 'Há arquivos próprios ou alterados em conflito. Revise o plano; --force salva backup antes de substituir.',
@@ -15,6 +16,7 @@ const messages = {
15
16
  agentLanguage: 'Responda em português, salvo quando o usuário solicitar outro idioma.', inspect: 'Verifique a instalação', workflow: 'Consulte a próxima etapa', install: 'Inicialize este projeto', repair: 'Confira a sincronização', review: 'Revise os arquivos em conflito', tools: 'Resultado da ferramenta',
16
17
  },
17
18
  en: {
19
+ preserved: 'Existing paths preserved', preservedDetails: 'Use --json to inspect paths and reasons.',
18
20
  help: 'AgentDevKit: init [directory], update, doctor, sync [--check], lang [pt|en|es], agents [list], next, tools <action>.\nOptions: --target <directory>, --lang <language>, --agents <list>, --yes, --dry-run, --force, --json.\nAliases: --clients and clients.',
19
21
  clientsCurrent: 'Selected agents', clientsAvailable: 'Available agents', clientsSelect: 'Agents (comma-separated IDs or all) [all]: ', agents: 'Agent selection updated.',
20
22
  next: 'Next step', init: 'Structure installed.', update: 'Structure updated.', sync: 'Pointers synchronized.', lang: 'Language configured.', unchanged: 'Structure is already current.', plan: 'Plan calculated; no files changed.', conflict: 'User files or edits conflict with this plan. Review it; --force backs them up before replacement.',
@@ -23,6 +25,7 @@ const messages = {
23
25
  agentLanguage: 'Respond in English unless the user requests another language.', inspect: 'Check the installation', workflow: 'Review the next stage', install: 'Initialize this project', repair: 'Check synchronization', review: 'Review conflicting files', tools: 'Tool result',
24
26
  },
25
27
  es: {
28
+ preserved: 'Rutas existentes preservadas', preservedDetails: 'Use --json para consultar las rutas y los motivos.',
26
29
  help: 'AgentDevKit: init [directorio], update, doctor, sync [--check], lang [pt|en|es], agents [lista], next, tools <acción>.\nOpciones: --target <directorio>, --lang <idioma>, --agents <lista>, --yes, --dry-run, --force, --json.\nAlias: --clients y clients.',
27
30
  clientsCurrent: 'Agentes seleccionados', clientsAvailable: 'Agentes disponibles', clientsSelect: 'Agentes (IDs separados por coma o all) [all]: ', agents: 'Selección de agentes actualizada.',
28
31
  next: 'Próximo paso', init: 'Estructura instalada.', update: 'Estructura actualizada.', sync: 'Referencias sincronizadas.', lang: 'Idioma configurado.', unchanged: 'La estructura ya está actualizada.', plan: 'Plan calculado; ningún archivo modificado.', conflict: 'Hay archivos propios o cambios en conflicto. Revise el plan; --force guarda una copia antes de reemplazarlos.',
package/src/install.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, lstatSync, readFileSync, writeFileSync, readdirSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, rmdirSync } from 'node:fs';
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, writeFileSync, readdirSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, rmdirSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { synchronize, isReference } from '../.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
6
6
  import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack } from '../.pbai4devs/scripts/lib/gitignore.mjs';
7
+ import { nativePathBoundary } from '../.pbai4devs/scripts/lib/native-paths.mjs';
7
8
  import { writeAdapterManifest, adapterRoots, normalizeClients, resolveAdapters } from './adapters/index.mjs';
8
9
  import { packageRoot, packageInfo, statePath, entryNames, begin, end } from './config.mjs';
9
10
  import { normalizeLanguage, text } from './i18n.mjs';
@@ -77,8 +78,24 @@ function walk(root, path = '', filter = () => true) {
77
78
  return files;
78
79
  }
79
80
 
80
- function write(root, path, content) {
81
- const target = safePath(root, path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content);
81
+ function write(root, path, content, mode) {
82
+ const target = safePath(root, path); mkdirSync(dirname(target), { recursive: true });
83
+ writeFileSync(target, content, mode === undefined ? undefined : { mode });
84
+ if (mode !== undefined) chmodSync(target, mode);
85
+ }
86
+
87
+ function atomicWrite(root, path, content, mode, onPublished) {
88
+ const file = safePath(root, path);
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const temporary = `${file}.agentdevkit-${randomUUID()}`;
91
+ try {
92
+ writeFileSync(temporary, content, { flag: 'wx', ...(mode === undefined ? {} : { mode }) });
93
+ if (mode !== undefined) chmodSync(temporary, mode);
94
+ const writtenMode = stat(temporary).mode & 0o777;
95
+ renameSync(temporary, file);
96
+ onPublished?.(writtenMode);
97
+ return writtenMode;
98
+ } finally { rmSync(temporary, { force: true }); }
82
99
  }
83
100
 
84
101
  function read(root, path) {
@@ -157,6 +174,24 @@ function copyResources(source, destination) {
157
174
  return files;
158
175
  }
159
176
 
177
+ function readDerivedState(target) {
178
+ let entries = {}, outputs = {};
179
+ const derived = read(target, '.pbai4devs/estado/derived.json');
180
+ if (derived) {
181
+ try {
182
+ const value = JSON.parse(derived);
183
+ if (value.schemaVersion !== 1 || !value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)
184
+ || value.outputs !== undefined && (!value.outputs || typeof value.outputs !== 'object' || Array.isArray(value.outputs))) fail('E_STATE', 'Invalid derived entrypoint state.');
185
+ entries = value.entries; outputs = value.outputs ?? {};
186
+ for (const [path, digest] of Object.entries(entries)) if (!entryNames.includes(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived entrypoint state.');
187
+ for (const [path, digest] of Object.entries(outputs)) if (!allowedManaged(path) || !/^\.[^/]+\/(?:skills|agents|rules)\.json$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived output state.');
188
+ }
189
+ catch { fail('E_STATE', 'Invalid derived entrypoint state.'); }
190
+ for (const path of [...Object.keys(entries), ...Object.keys(outputs)]) safePath(target, path);
191
+ }
192
+ return { entries, outputs };
193
+ }
194
+
160
195
  export function planInstallation({ target, language = 'pt', mode = 'init', force = false, clients }) {
161
196
  const previous = loadState(target);
162
197
  if (mode !== 'init' && !previous) fail('E_MISSING', text(language, 'missing'));
@@ -164,16 +199,37 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
164
199
  const active = resolveAdapters(selected);
165
200
  const changingClients = clients !== undefined || mode === 'agents';
166
201
  const protectedEntries = new Set(), entryConflicts = [], ownedEntrypoints = new Set();
202
+ const { entries: derivedEntries, outputs: derivedOutputs } = readDerivedState(target);
203
+ const ownedPaths = [...Object.keys(previous?.files || {}), ...Object.keys(previous?.blocks || {}), ...Object.keys(derivedEntries), ...Object.keys(derivedOutputs)];
204
+ const preserved = new Map();
205
+ const recordPreserved = (path, reason) => { preserved.set(path, { path, reason }); return true; };
206
+ const native = path => entryNames.includes(path) || path === 'README.md' || adapterRoots.some(root => path === root || path.startsWith(`${root}/`));
207
+ const boundary = path => {
208
+ if (!native(path)) return false;
209
+ const found = nativePathBoundary(target, path, ownedPaths, { preserveRootAliases: path === 'README.md' || entryNames.includes(path) });
210
+ return found ? recordPreserved(found.path, found.reason) : false;
211
+ };
212
+ const owned = path => ownedPaths.includes(path);
213
+ const generatedReference = content => Boolean(content && isReference(content.toString('utf8'))
214
+ && (content.toString('utf8').split(/\r?\n/).includes('<!-- gerado: sincronizar-agentes -->') || content.toString('utf8').startsWith('# gerado: sincronizar-agentes\n')));
215
+ const preserveNative = path => {
216
+ if (!native(path)) return false;
217
+ if (boundary(path)) return true;
218
+ const item = stat(safePath(target, path));
219
+ if (!item || owned(path)) return false;
220
+ if (!item.isFile() && ownedPaths.some(value => value.startsWith(`${path}/`))) fail('E_PATH', `Managed subtree occupies a file destination: ${path}`);
221
+ if (item.isFile()) {
222
+ const current = read(target, path);
223
+ if (generatedReference(current)) return false;
224
+ if (path.endsWith('/.gitignore') && current.toString('utf8').split(/\r?\n/).some(line => ['# BEGIN AGENT IGNORE', '# END AGENT IGNORE'].includes(line))) return false;
225
+ }
226
+ return recordPreserved(path, item.isFile() ? 'existing-file' : 'existing-directory');
227
+ };
167
228
  for (const name of entryNames) {
229
+ if (preserveNative(name)) continue;
168
230
  const current = read(target, name);
169
231
  if (current && previous?.files[name] && previous.files[name] !== hash(current)) { protectedEntries.add(name); entryConflicts.push(name); }
170
232
  }
171
- let derivedEntries = {};
172
- const derived = read(target, '.pbai4devs/estado/derived.json');
173
- if (derived) {
174
- try { const value = JSON.parse(derived); if (value.schemaVersion === 1 && value.entries && typeof value.entries === 'object') derivedEntries = value.entries; }
175
- catch { fail('E_STATE', 'Invalid derived entrypoint state.'); }
176
- }
177
233
  const stage = realpathSync(mkdtempSync(join(tmpdir(), 'agentdevkit-')));
178
234
  try {
179
235
  const existing = copyResources(join(target, '.pbai4devs'), join(stage, '.pbai4devs'));
@@ -190,6 +246,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
190
246
  if (path.startsWith('.pbai4devs/') && !supplied.has(path.slice(11)) && !internal.has(path.slice(11))) rmSync(safePath(stage, path), { force: true });
191
247
  }
192
248
  for (const root of active.roots) {
249
+ if (preserveNative(`${root}/.gitignore`)) continue;
193
250
  const content = read(target, `${root}/.gitignore`);
194
251
  if (content) write(stage, `${root}/.gitignore`, content);
195
252
  }
@@ -198,7 +255,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
198
255
  writeAdapterManifest(stage, { clients: selected });
199
256
  const synchronized = synchronize({ root: stage, force: true, localIgnores: false, generateEntrypoints: false, output() {} });
200
257
  for (const name of active.entrypoints) {
201
- if (protectedEntries.has(name)) continue;
258
+ if (protectedEntries.has(name) || preserveNative(name)) continue;
202
259
  const current = read(target, name), template = readFileSync(join(packageRoot, '.pbai4devs/templates/entrypoints', name), 'utf8');
203
260
  const legacy = entryContent(null, template, language, name);
204
261
  const content = legacy.replace(`${begin}\n`, '').replace(`\n${end}`, '');
@@ -206,11 +263,18 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
206
263
  const knownLegacy = current && previous?.blocks[name] && current.toString('utf8') === entryContent(null, template, previous.language, name);
207
264
  if (!current || knownFile || knownLegacy) { write(stage, name, content); ownedEntrypoints.add(name); }
208
265
  }
209
- if (!read(target, 'README.md')) write(stage, 'README.md', `---\ndescription: ${JSON.stringify(text(language, 'readme'))}\nlanguage: ${language}\n---\n\n# ${text(language, 'project')}\n\n${text(language, 'readme')}\n`);
266
+ if (!preserveNative('README.md') && !read(target, 'README.md')) write(stage, 'README.md', `---\ndescription: ${JSON.stringify(text(language, 'readme'))}\nlanguage: ${language}\n---\n\n# ${text(language, 'project')}\n\n${text(language, 'readme')}\n`);
210
267
  if (!existsSync(join(stage, '.pbai4devs/estado/README.md'))) write(stage, '.pbai4devs/estado/README.md', `---\ndescription: AgentDevKit state.\n---\n\n# AgentDevKit\n\n${text(language, 'state')}\n`);
211
268
  const unknown = new Set(existing.filter(path => !supplied.has(path) && !previous?.files[`.pbai4devs/${path}`] && !internal.has(path)));
212
269
  const paths = walk(stage, '', path => !path.split('/').includes('.agent-tests')).filter(path => !path.startsWith('.pbai4devs/') || !unknown.has(path.slice(11)));
213
- const desired = new Map(paths.map(path => [path, readFileSync(join(stage, path))]));
270
+ const desired = new Map(paths.filter(path => !preserveNative(path)).map(path => [path, readFileSync(join(stage, path))]));
271
+ // Native discovery manifests must not advertise resources omitted at a human boundary.
272
+ const overlapsPreserved = path => [...preserved.keys()].some(kept => path === kept || path.startsWith(`${kept}/`) || kept.startsWith(`${path}/`));
273
+ for (const [path, content] of desired) if (/^\.agents\/(skills|rules|agents)\.json$/.test(path)) {
274
+ const discovery = JSON.parse(content);
275
+ discovery.entries = discovery.entries.filter(entry => !overlapsPreserved(entry.path));
276
+ desired.set(path, Buffer.from(`${JSON.stringify(discovery, null, 2)}\n`));
277
+ }
214
278
  const manifest = { schemaVersion: 1, version: bundled.length ? packageInfo.version : previous.version, language, files: {}, blocks: {} };
215
279
  if (mode !== 'sync' || clients !== undefined || previous?.clients !== undefined) manifest.clients = selected;
216
280
  for (const [path, content] of desired) {
@@ -224,7 +288,8 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
224
288
  for (const [path, content] of desired) {
225
289
  const current = read(target, path);
226
290
  if (current?.equals(content)) continue;
227
- const managed = previous?.blocks[path] ? blockDigest(path, current || Buffer.alloc(0)) === previous.blocks[path] : previous?.files[path] ? hash(current || '') === previous.files[path] : false;
291
+ const managed = previous?.blocks[path] ? blockDigest(path, current || Buffer.alloc(0)) === previous.blocks[path]
292
+ : [previous?.files[path], derivedOutputs[path], derivedEntries[path]].some(digest => digest && hash(current || '') === digest);
228
293
  const appendBlock = entryNames.includes(path) || path === '.gitignore' || path.endsWith('/.gitignore');
229
294
  const conflict = current && path !== statePath && !managed && !appendBlock;
230
295
  const changedBlock = current && previous?.blocks[path] && !managed;
@@ -254,11 +319,29 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
254
319
  }
255
320
  changes.push({ path, action: content.trim() ? 'update' : 'remove', content: content.trim() ? Buffer.from(content) : null, beforeHash: hash(current) });
256
321
  }
257
- for (const root of changingClients ? [] : active.roots) for (const path of walk(join(target, root), '', path => !path.split('/').some(part => ignored.has(part)))) {
322
+ function nativeFiles(root) {
323
+ if (boundary(root)) return [];
324
+ const directory = stat(safePath(target, root));
325
+ if (!directory?.isDirectory()) return [];
326
+ const files = [];
327
+ const visit = folder => {
328
+ for (const name of readdirSync(safePath(target, folder)).sort()) {
329
+ const path = `${folder}/${name}`;
330
+ if (boundary(path)) continue;
331
+ const info = stat(safePath(target, path));
332
+ if (path.split('/').some(part => ignored.has(part))) continue;
333
+ if (info?.isDirectory()) visit(path);
334
+ else if (info?.isFile()) files.push(path);
335
+ }
336
+ };
337
+ visit(root); return files;
338
+ }
339
+ for (const root of changingClients ? [] : active.roots) for (const full of nativeFiles(root)) {
340
+ const path = full.slice(root.length + 1);
258
341
  if (!['skills', 'agents', 'commands', 'rules', 'instructions', 'prompts'].includes(path.split('/')[0])) continue;
259
- const full = `${root}/${path}`; if (desired.has(full) || changes.some(change => change.path === full)) continue;
342
+ if (desired.has(full) || changes.some(change => change.path === full)) continue;
260
343
  const current = read(target, full), source = current.toString('utf8');
261
- if (!(source.includes('<!-- gerado: sincronizar-agentes -->') || source.startsWith('# gerado: sincronizar-agentes\n'))) continue;
344
+ if (!(source.includes('<!-- gerado: sincronizar-agentes -->') || source.startsWith('# gerado: sincronizar-agentes\n'))) { recordPreserved(full, 'existing-file'); continue; }
262
345
  if (!isReference(source)) conflicts.push(full);
263
346
  changes.push({ path: full, action: 'remove', content: null, beforeHash: hash(current) });
264
347
  }
@@ -269,7 +352,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
269
352
  changes.push({ path: legacy, action: 'remove', content: null, beforeHash: hash(legacyContent) });
270
353
  }
271
354
  const retiredRoots = adapterRoots.filter(root => !active.roots.includes(root) && changes.some(change => change.action === 'remove' && change.path.startsWith(`${root}/`) && (previous?.files[change.path] || previous?.blocks[change.path])));
272
- const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])];
355
+ const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])].filter(path => !overlapsPreserved(path));
273
356
  const hashes = {};
274
357
  for (const [path, digest] of Object.entries(previous?.files || {})) if ((ownedOutputs.includes(path) || !path.startsWith('.pbai4devs/')) && !protectedEntries.has(path)) hashes[path] = digest;
275
358
  for (const path of ownedEntrypoints) { const current = read(target, path); if (current && (previous?.blocks[path] || derivedEntries[path] === hash(current))) hashes[path] = hash(current); }
@@ -282,7 +365,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
282
365
  const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
283
366
  const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
284
367
  conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
285
- return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: (!conflicts.length || force) && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck };
368
+ return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: (!conflicts.length || force) && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
286
369
  } finally { rmSync(stage, { recursive: true, force: true }); }
287
370
  }
288
371
 
@@ -294,42 +377,63 @@ export function applyPlan(plan) {
294
377
  safePath(target, backup);
295
378
  for (const change of plan.changes) safePath(target, change.path);
296
379
  const before = new Map(plan.changes.map(change => [change.path, read(target, change.path)]));
380
+ const beforeModes = new Map(plan.changes.map(change => {
381
+ const info = stat(safePath(target, change.path));
382
+ return [change.path, info ? info.mode & 0o777 : undefined];
383
+ }));
297
384
  for (const change of plan.changes) if ((before.get(change.path) ? hash(before.get(change.path)) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
298
- for (const [path, content] of before) if (content) write(target, `${backup}/${path}`, content);
299
- const applied = [];
385
+ for (const [path, content] of before) if (content) write(target, `${backup}/${path}`, content, beforeModes.get(path));
386
+ const applied = [], removedDirectories = [];
300
387
  let untracked = false;
301
388
  try {
302
389
  if (plan.untrack?.paths.length) { applyUntrack(plan.untrack); untracked = true; }
303
390
  for (const change of plan.changes) {
304
391
  const file = safePath(target, change.path);
305
- if (change.content === null) rmSync(file, { force: true });
306
- else {
307
- mkdirSync(dirname(file), { recursive: true });
308
- const temporary = `${file}.agentdevkit-${randomUUID()}`;
309
- try { writeFileSync(temporary, change.content, { flag: 'wx' }); renameSync(temporary, file); }
310
- finally { rmSync(temporary, { force: true }); }
311
- }
312
- applied.push(change.path);
392
+ const content = change.content;
393
+ const signature = { path: change.path, hash: content === null ? null : hash(content) };
394
+ if (content === null) { rmSync(file, { force: true }); applied.push(signature); }
395
+ else atomicWrite(target, change.path, content, beforeModes.get(change.path), mode => {
396
+ signature.mode = mode; applied.push(signature);
397
+ });
313
398
  }
314
- pruneRemovedDirectories(plan);
399
+ pruneRemovedDirectories(plan, directory => removedDirectories.push(directory));
315
400
  const changesFallback = plan.changes.some(change => change.path === '.gitignore');
316
401
  // Metadata is committed last; its writer rolls back partial writes itself.
317
402
  const ignored = applyLocalIgnore(changesFallback ? planLocalIgnore(target, { outputs: plan.ownedOutputs }) : plan.localIgnore);
318
403
  return { changed: Boolean(plan.changes.length || ignored.changed || untracked), backup: [...before.values()].some(Boolean) ? backup : null };
319
404
  } catch (error) {
320
- const rollbackErrors = [];
321
- for (const path of applied.reverse()) {
405
+ const rollbackErrors = [], preserved = [];
406
+ for (const removed of [...removedDirectories].sort((a, b) => a.path.length - b.path.length)) {
407
+ let directory, current;
408
+ try { directory = safePath(target, removed.path); current = stat(directory); }
409
+ catch { preserved.push(removed.path); continue; }
410
+ if (current) continue;
411
+ try { mkdirSync(directory, { mode: removed.mode }); chmodSync(directory, removed.mode); }
412
+ catch (failure) { rollbackErrors.push(failure); }
413
+ }
414
+ for (const appliedFile of applied.reverse()) {
415
+ const { path } = appliedFile;
416
+ let unchanged = false;
417
+ try {
418
+ const file = safePath(target, path), info = stat(file);
419
+ unchanged = appliedFile.hash === null ? !info : Boolean(info?.isFile()
420
+ && (info.mode & 0o777) === appliedFile.mode && hash(readFileSync(file)) === appliedFile.hash);
421
+ } catch { /* Não restaurar quando não é possível comprovar a própria gravação. */ }
422
+ if (!unchanged) { preserved.push(path); continue; }
322
423
  const content = before.get(path);
323
- try { if (content) write(target, path, content); else rmSync(safePath(target, path), { force: true }); }
424
+ try { if (content) atomicWrite(target, path, content, beforeModes.get(path)); else rmSync(safePath(target, path), { force: true }); }
324
425
  catch (failure) { rollbackErrors.push(failure); }
325
426
  }
427
+ if (preserved.length) {
428
+ try { error.rollbackPreservedPaths = preserved; } catch { /* Preserve a exceção original. */ }
429
+ }
326
430
  if (untracked) try { restoreUntrack(plan.untrack); } catch (failure) { rollbackErrors.push(failure); }
327
431
  if (rollbackErrors.length) throw Object.assign(new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error }), { code: error.code || 'E_OPERATION' });
328
432
  throw error;
329
433
  }
330
434
  }
331
435
 
332
- function pruneRemovedDirectories(plan) {
436
+ function pruneRemovedDirectories(plan, onRemoved) {
333
437
  const { target } = plan;
334
438
  const legacy = '.pbai4devs/adapters/README.md';
335
439
  const removed = plan.changes.filter(change => change.action === 'remove' && (allowedManaged(change.path) || change.path === legacy));
@@ -342,8 +446,9 @@ function pruneRemovedDirectories(plan) {
342
446
  const boundary = parent.startsWith('.pbai4devs/') ? '.pbai4devs' : adapterRoots.find(root => parent.startsWith(`${root}/`));
343
447
  while (boundary && parent.startsWith(`${boundary}/`)) {
344
448
  const directory = safePath(target, parent);
345
- if (!stat(directory)?.isDirectory() || readdirSync(directory).length) break;
346
- try { rmdirSync(directory); }
449
+ const info = stat(directory);
450
+ if (!info?.isDirectory() || readdirSync(directory).length) break;
451
+ try { rmdirSync(directory); onRemoved?.({ path: parent, mode: info.mode & 0o777 }); }
347
452
  catch (error) { if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) break; throw error; }
348
453
  parent = portable(dirname(parent));
349
454
  }
@@ -351,8 +456,9 @@ function pruneRemovedDirectories(plan) {
351
456
  for (const root of plan.retiredRoots || []) {
352
457
  if (!adapterRoots.includes(root)) continue;
353
458
  const directory = safePath(target, root);
354
- if (!stat(directory)?.isDirectory()) continue;
355
- try { if (readdirSync(directory).length === 0) rmdirSync(directory); }
459
+ const info = stat(directory);
460
+ if (!info?.isDirectory()) continue;
461
+ try { if (readdirSync(directory).length === 0) { rmdirSync(directory); onRemoved?.({ path: root, mode: info.mode & 0o777 }); } }
356
462
  catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
357
463
  }
358
464
  }
@@ -360,10 +466,11 @@ function pruneRemovedDirectories(plan) {
360
466
  export function inspect(target) {
361
467
  const state = loadState(target);
362
468
  if (!state) return { installed: false, issues: [{ code: 'missing', path: statePath }] };
469
+ const derived = readDerivedState(target);
363
470
  const issues = [];
364
471
  for (const [path, digest] of Object.entries(state.files)) {
365
472
  const content = read(target, path);
366
- if (!content || hash(content) !== digest) issues.push({ code: content ? 'modified' : 'missing', path });
473
+ if (!content || hash(content) !== digest && hash(content) !== derived.outputs[path]) issues.push({ code: content ? 'modified' : 'missing', path });
367
474
  }
368
475
  for (const [path, digest] of Object.entries(state.blocks)) {
369
476
  const content = read(target, path);