@softize/opus 11.1.1 → 12.0.1

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 (44) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +31 -1
  3. package/bin/cli.mjs +97 -27
  4. package/bin/lib/check.mjs +55 -43
  5. package/bin/lib/copy.mjs +2202 -0
  6. package/bin/lib/create.mjs +227 -39
  7. package/bin/lib/db-migrate-runner.mjs +9 -6
  8. package/bin/lib/db-project-path.mjs +20 -0
  9. package/bin/lib/db-scaffold-runner.mjs +23 -8
  10. package/bin/lib/db.mjs +6 -4
  11. package/bin/lib/gen.mjs +60 -29
  12. package/bin/lib/init.mjs +212 -56
  13. package/bin/lib/introspect.mjs +3 -2
  14. package/bin/lib/materialize.mjs +623 -97
  15. package/bin/lib/postinstall.mjs +6 -5
  16. package/bin/lib/validate-skill.mjs +502 -30
  17. package/docs/code-style.md +142 -7
  18. package/docs/consumer-upgrade-propagation.md +4 -3
  19. package/docs/releasing.md +28 -17
  20. package/package.json +6 -1
  21. package/registry/git/pre-push.d/00-opus-copy +14 -0
  22. package/registry/git/pre-push.d/opus +7 -21
  23. package/registry/git/run-opus-pre-push.mjs +141 -0
  24. package/registry/hooks/opus-check-on-stop.mjs +13 -31
  25. package/registry/instructions/opus.md +11 -5
  26. package/registry/skills/build-opus-ui/SKILL.md +5 -4
  27. package/registry/skills/create-opus-action/SKILL.md +4 -4
  28. package/registry/skills/implement-opus-change/SKILL.md +7 -5
  29. package/registry/skills/upgrade-opus/SKILL.md +8 -4
  30. package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
  31. package/registry/templates/app/package.json +4 -0
  32. package/registry/templates/app/pnpm-workspace.yaml +3 -2
  33. package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
  34. package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
  35. package/src/ui/components/patterns/split.tsx +66 -24
  36. package/src/ui/docs/content/cli.md +8 -7
  37. package/src/ui/docs/content/communication.md +79 -126
  38. package/src/ui/docs/content/getting-started.md +29 -16
  39. package/src/ui/docs/content/split.md +43 -3
  40. package/src/ui/react.tsx +1 -1
  41. package/registry/skills/write-product-communication/SKILL.md +0 -28
  42. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  43. package/registry/templates/app/_npmrc +0 -1
  44. package/registry/templates/monorepo/_npmrc +0 -1
@@ -5,12 +5,147 @@ order: 5
5
5
 
6
6
  # Code style — padrão softize
7
7
 
8
- Este arquivo registra as convenções editoriais da documentação do Opus. Regras
9
- transversais de engenharia pertencem à `@softize/base`; o Opus mantém apenas o que é
10
- específico do SDK e seus gates.
8
+ Este arquivo registra somente convenções específicas do Opus. Princípios gerais de
9
+ comunicação humana pertencem à skill `write-product-communication` da `@softize/base`;
10
+ as regras universais mecanicamente verificáveis de copy pertencem à política versionada,
11
+ em `docs/copy-policy.md` daquele pacote.
11
12
 
12
- Resumo de uma linha: **código em inglês; o que humano lê (UI, comentário, doc) em pt-BR;
13
- frases com maiúscula e ponto; labels curtos sem ponto.**
13
+ No Opus, `label`, `messages`, `fields`, `filters`, `columns`, confirmação e demais textos
14
+ de interface vivem no contrato quando descrevem a operação, não numa tela isolada. O comando
15
+ `opus copy` projeta essas propriedades para o inventário semântico que a Base valida; ele não
16
+ reimplementa a política.
14
17
 
15
- O que dá, é regra executável no `opus check` (naming `<resource>.<verb>`, ordem dos
16
- campos do spec, registro no runtime); o restante vem das instruções versionadas no repo.
18
+ As convenções executáveis próprias do SDK continuam no `opus check`: naming
19
+ `<resource>.<verb>`, ordem dos campos do spec, segurança e registro no runtime.
20
+
21
+ ## Inventário de copy
22
+
23
+ ```bash
24
+ pnpm exec opus copy # regenera atomicamente o arquivo derivado
25
+ pnpm exec opus copy --check # CI/pre-push: compara sem escrever
26
+ pnpm exec base copy check # aplica a política universal
27
+ ```
28
+
29
+ O inventário `.base/copy-inventory.json` é derivado e versionado. No protocolo
30
+ `source-manifest-v1`, cada arquivo JavaScript/TypeScript efetivamente analisado entra com
31
+ SHA-256, inclusive quando não contém uma superfície Opus ou produz zero entradas. O bloco
32
+ `coverage` registra `analyzedFiles`, fontes distintas que produziram copy e entradas extraídas;
33
+ as contagens são derivadas do próprio manifesto. Zero texto é válido após analisar uma fonte,
34
+ mas zero arquivo não comprova execução e reprova. O pre-push roda `00-opus-copy` antes do
35
+ fragmento `base`: ausência ou drift falham sem modificar silenciosamente o worktree.
36
+ O caminho do inventário precisa permanecer dentro da raiz real do projeto; nenhum diretório nem
37
+ o próprio arquivo pode ser link simbólico, tanto na leitura quanto na escrita. Links simbólicos
38
+ no escopo de source também reprovam (a menos que o caminho esteja explicitamente excluído), em
39
+ vez de reduzir a cobertura em silêncio.
40
+
41
+ O extrator é deliberadamente estático. Aceita texto literal, concatenação de literais,
42
+ `I18nRef` com `default` literal, constantes `const` no escopo lexical correto, propriedades
43
+ computadas com chave literal e spreads de objetos/arrays igualmente estáticos. Duplicatas e
44
+ spreads seguem a ordem real do JavaScript; um override posterior conhecido pode fechar uma
45
+ incerteza anterior. Em contratos
46
+ e em props, `aria-label` ou filhos de componentes Opus mapeados, uma superfície presente mas
47
+ dinâmica, importada sem fallback ou composta de forma opaca reprova a extração. Isso torna
48
+ lacunas conhecidas visíveis em vez de produzir um inventário verde e incompleto.
49
+
50
+ Transformações visuais não observáveis e exceções ficam explícitas em `base.json`, usando um
51
+ seletor estável e revisável. `source`, `line` e `role` devem identificar exatamente uma entrada;
52
+ `text` é um refinamento opcional. Mudanças que invalidam o seletor reprovam a extração. Metadata
53
+ pode complementar a análise, mas nunca sobrescrever uma transformação já observada no JSX —
54
+ `transform: "none"` não mascara `className="uppercase"`. O Opus valida o shape `COPYnnn`; a
55
+ Base instalada é dona do catálogo, dos kinds aceitos e do fundamento de cada regra:
56
+
57
+ ```json
58
+ {
59
+ "copy": {
60
+ "inventory": ".base/copy-inventory.json",
61
+ "allowedUppercase": ["CPF"],
62
+ "exclude": ["scripts/fixtures/**"],
63
+ "transforms": [
64
+ { "source": "src/account.ts", "line": 12, "role": "button", "transform": "uppercase" }
65
+ ],
66
+ "exemptions": [
67
+ {
68
+ "source": "src/account.ts",
69
+ "line": 18,
70
+ "role": "label",
71
+ "ruleId": "COPY001",
72
+ "kind": "external-requirement",
73
+ "reason": "Código regulatório exibido na grafia oficial.",
74
+ "reference": "regulation:ABC-123"
75
+ }
76
+ ]
77
+ }
78
+ }
79
+ ```
80
+
81
+ | Propriedade Opus | Papel no protocolo Base |
82
+ |---|---|
83
+ | `label`, `confirm.*Label`, `ActionTrigger.label` | `button` |
84
+ | `title`, `examples[].name` | `title` |
85
+ | `summary`, `description`, `examples[].description`, `expand.*.description` | `description` |
86
+ | `messages.success`, `messages.error`, `messages.confirmation` | `success`, `error`, `message` |
87
+ | `confirm.message` | `dialog-body` |
88
+ | `fields.*.label`, `filters.*.label` | `label` |
89
+ | `placeholder`, `hint`, `help` | `placeholder`, `helper-text` |
90
+ | opções estáticas | `menu-item` |
91
+ | `columns[].label`, `periods[].label` | `heading`, `tab` |
92
+ | `Select.emptyText`, `Select.searchPlaceholder` | `empty-state`, `placeholder` |
93
+ | `Select.options[].hint/triggerLabel/group` | `label`, `label`, `heading` |
94
+ | `ActionTrigger.confirm.*` | papel correspondente do diálogo |
95
+
96
+ ## Cobertura e significado do gate verde
97
+
98
+ O extrator cobre duas superfícies, sem heurística de nome:
99
+
100
+ 1. propriedades estáticas de `defineAction` e `defineContract` descritas acima, somente
101
+ quando a factory resolve por símbolo a um import de `@softize/opus` ou
102
+ `@softize/opus/core`; alias, namespace e destructuring `const` de namespace funcionam,
103
+ homônimo local não é contrato;
104
+ 2. texto estático em filhos e props de uma allowlist de componentes importados diretamente
105
+ de `@softize/opus/ui*` — por exemplo, `Button`, `DialogTitle`, `DialogDescription`,
106
+ `FieldLabel`, `TabsTrigger`, `Page`, `Input` e `DataState`. Alias e namespace de import
107
+ continuam rastreáveis. `uppercase`, variantes como `sm:hover:uppercase`, modificadores
108
+ `!uppercase`/`uppercase!` e classes em descendentes são projetados na superfície que
109
+ realmente herda o CSS. Props específicas também respeitam a implementação: por exemplo,
110
+ `ActionFormCard.cardClassName` alcança título/descrição, enquanto `className` alcança só o
111
+ form; o portal do Select custom não herda a classe do gatilho. `className` dinâmico numa
112
+ superfície afetada reprova, pois poderia aplicar a transformação sem registrá-la. Ícones
113
+ decorativos em filhos devem declarar
114
+ `aria-hidden="true"`; componentes somente com ícone usam um `aria-label` estático como
115
+ nome acessível. Um `aria-label` não apaga nem substitui copy visual: texto visível aninhado
116
+ estático e inequívoco também entra no inventário, inclusive quando um ancestral
117
+ `aria-hidden` o remove apenas da árvore de acessibilidade; folhas sob esse ancestral são
118
+ decorativas, sem mascarar texto visual. `null`, booleanos e `void` não viram copy.
119
+ Composições em múltiplos fragmentos que exigiriam adivinhar espaçamento
120
+ ou accessible name também reprovam em vez de gerar um texto diferente do renderizado.
121
+
122
+ `Select` custom cobre `placeholder`, `searchPlaceholder`, `emptyText` e cada campo textual de
123
+ `options`; no modo nativo inventaria somente o que o `<select>` realmente renderiza
124
+ (`placeholder`, `label` e `group`). A classe do Select alcança o placeholder e o rótulo/chip
125
+ do gatilho custom, mas não busca, vazio, hint ou menu no portal. `ActionTrigger` cobre `label`
126
+ e todo o objeto `confirm`; sua classe alcança o label do botão, mas não confirmação nem tooltip
127
+ do modo ícone. `itemLabel` fica fora de
128
+ propósito: ele identifica o dado alvo em runtime (por exemplo, `customer.name`), não uma frase
129
+ autoral da interface. Arrays, objetos ou discriminadores dinâmicos falham visivelmente.
130
+ `content`, `triggerLabel` e demais `ReactNode` mapeados aceitam uma composição estática e
131
+ inequívoca; entidades JSX são decodificadas antes do inventário. Fragmentos cuja concatenação
132
+ exigiria adivinhar espaços continuam reprovando.
133
+
134
+ Um `opus copy --check` verde **não significa que toda a copy do produto foi coberta**. Ficam
135
+ fora, deliberadamente:
136
+
137
+ - JSX nativo (`h1`, `p`, `button`) e componentes locais/wrappers, pois o Opus não conhece
138
+ seu papel semântico;
139
+ - texto vindo de API, banco, catálogo i18n importado ou variável de runtime em superfícies
140
+ que o Opus não classifica. Quando esse conteúdo ocupa uma prop ou filho mapeado, a extração
141
+ falha e pede um fallback estático;
142
+ - props ou componentes de terceiros sem contrato Opus;
143
+ - copy escondida em CSS ou pseudo-elemento. Para uma transformação conhecida que o AST não
144
+ enxerga globalmente, use `copy.transforms`; em componente Opus mapeado, `className` opaco
145
+ reprova para não mascarar `uppercase`.
146
+
147
+ Essas superfícies continuam obrigatórias na revisão humana/por IA da Base. O verde do gate
148
+ prova o manifesto dos arquivos que o extrator declara ter analisado e a conformidade mecânica
149
+ **das entradas inventariadas**, não cobertura total do repositório nem da interface. Ampliar a
150
+ allowlist exige papel inequívoco no componente Opus e teste; não se infere semântica pelo nome
151
+ de componente do consumidor.
@@ -10,7 +10,7 @@ pretende usar.
10
10
 
11
11
  Há três sinais diferentes e eles não devem ser confundidos:
12
12
 
13
- 1. **disponível:** versão publicada no registry;
13
+ 1. **disponível:** versão publicada no npm;
14
14
  2. **adotada:** versão comitada na branch principal do consumidor;
15
15
  3. **aplicada:** versão presente na worktree e nos artefatos materializados.
16
16
 
@@ -21,7 +21,8 @@ Há três sinais diferentes e eles não devem ser confundidos:
21
21
  - A branch principal de cada consumidor é a autoridade sobre a versão adotada. Uma versão
22
22
  apenas publicada não bloqueia trabalho nem CI.
23
23
  - Quando um consumidor decide atualizar, a skill `upgrade-opus` lê o changelog, atualiza
24
- manifests e lockfile, executa `opus setup` e conduz as migrações no próprio contexto.
24
+ manifests e lockfile, executa `pnpm run setup` (ou o script filtrado do workspace) e
25
+ conduz as migrações no próprio contexto.
25
26
  - O Maestro compara a versão aplicada na sessão com a versão adotada pela branch principal e
26
27
  apresenta o drift. A correção continua sendo puxar/reconciliar a branch; o Maestro não
27
28
  reimplementa o upgrade do pacote.
@@ -33,7 +34,7 @@ Há três sinais diferentes e eles não devem ser confundidos:
33
34
  - Publicação e adoção permanecem desacopladas; cada consumidor atualiza no seu próprio fluxo
34
35
  e passa pelos seus gates.
35
36
  - Worktrees com trabalho em andamento nunca são sobrescritas por uma release.
36
- - O diagnóstico funciona sem consultar o registry durante cada commit ou review; isso evita
37
+ - O diagnóstico funciona sem consultar o npm durante cada commit ou review; isso evita
37
38
  transformar disponibilidade em política e mantém os gates reproduzíveis.
38
39
  - O conhecimento durável fica no changelog, nos detectores de migração e nas instruções
39
40
  materializadas, sem depender de uma infraestrutura externa de automação.
package/docs/releasing.md CHANGED
@@ -5,8 +5,8 @@ order: 6
5
5
 
6
6
  # Publicar o @softize/opus
7
7
 
8
- O Opus é publicado no **npm público** (`registry.npmjs.org`). O registry próprio foi
9
- aposentado; clientes instalam sem configuração ou token de leitura.
8
+ O Opus é publicado no **npm público** (`registry.npmjs.org`). Clientes instalam sem
9
+ configuração de escopo ou token de leitura.
10
10
 
11
11
  > O `publishConfig` do `package.json` já aponta para o npm — `pnpm publish` "só funciona",
12
12
  > sem `--registry`. Não precisa decorar a URL.
@@ -19,20 +19,20 @@ pnpm release minor # minor
19
19
  pnpm release major # major
20
20
  pnpm release 2.9.0 # versão explícita
21
21
  pnpm release none # publica sem bump
22
+ pnpm release none --dry-run # materializa e valida, sem publicar/commitar/push
22
23
  ```
23
24
 
24
25
  Auth: o token do npm no `~/.npmrc` global (`//registry.npmjs.org/:_authToken=…`),
25
26
  NÃO commitado.
26
27
 
27
- ## Não existe caminho pela CI (29/jul)
28
+ ## Publicação não roda na CI
28
29
 
29
30
  A camada de GitHub Actions foi **removida** deste repo — `.github/` não existe mais. Saíram
30
31
  o `release.yml` (publicava por tag `v*`), o `deploy.yml` (doc, já reserva) e o `check.yml`
31
32
  (typecheck+test em push/PR). É a mesma decisão do monorepo (ADR 0008 do softize): um
32
33
  caminho só, na estação, com os gates junto. Não há gatilho de reserva.
33
34
 
34
- O secret **`OPUS_REGISTRY_TOKEN`** ficou órfão nada mais o lê. Apague no GitHub. O que
35
- autentica hoje é o token do npm no seu `~/.npmrc` global.
35
+ O token usado para publicar é exclusivamente o token do npm no `~/.npmrc` global.
36
36
 
37
37
  **O que o `release.sh` absorveu**, porque sem CI ficaria sem dono:
38
38
 
@@ -40,32 +40,43 @@ autentica hoje é o token do npm no seu `~/.npmrc` global.
40
40
  `main`, com árvore limpa (`--porcelain`, que pega não-rastreado) e igual a `origin/main`.
41
41
  Rodam ANTES do bump, que suja a árvore de propósito. Antes isso vinha de graça: a release
42
42
  nascia de um checkout limpo da tag, e a árvore do dev nunca entrava no tarball.
43
- - **Bump na main, automático** — a guarda `tag=versão` do CI existia pra impedir registry e
44
- main divergindo em silêncio. Como aqui o bump acontece DEPOIS (o script versiona e
45
- publica), a proteção equivalente é o script commitar e empurrar o bump sozinho ao fim. Se
46
- o push falhar depois de o publish ter ido, ele grita e sai 1 a versão existe pro cliente
47
- e não existe no repo, e isso não pode passar como aviso no meio do log.
43
+ - **Bump e derivados na main** — o script bumpeia primeiro, materializa o Opus na raiz e a
44
+ Base a partir de `packages/opus` (o workspace que declara a dependência), regenera
45
+ inventário e então executa os gates. Antes de publicar, commita todo o diff produzido
46
+ (`package.json`, pins, inventário e projeções); depois do publish, empurra esse commit. Se
47
+ o push falhar, ele grita e sai 1 a versão existe para o cliente e o commit fica local
48
+ para recuperação imediata.
48
49
  - **Smoke do esqueleto** — já tinha descido pro script antes (ver abaixo).
49
50
 
50
51
  `--local` (Verdaccio) não passa pelas guardas de procedência: é sandbox, e exigir main limpa
51
52
  pra testar publicação seria a guarda atrapalhando quem está experimentando.
52
53
 
53
- **Gate de qualidade**: antes de bumpar/publicar roda `pnpm typecheck`
54
- + `pnpm test` e **aborta a release se qualquer um falhar**. Como o Opus **ship source** (`.ts`,
55
- sem build), essa é a última barreira antes do tarball — sem ela, um `tsc` vermelho vaza pro
56
- cliente. Emergência (evite): `pnpm release --skip-checks`.
54
+ **Gate de qualidade**: depois do bump e da materialização, mas antes de publicar, roda
55
+ `pnpm typecheck` + `pnpm test` + `pnpm copy:check` + `base copy check` e **aborta a release
56
+ se qualquer um falhar**. O Opus declara `@softize/base ^2.0.0` em `dependencies`, pois usa
57
+ suas APIs públicas de filesystem em runtime, e materializa os artefatos Base no próprio repo;
58
+ a release não baixa uma política ad hoc. Como o
59
+ Opus **ship source** (`.ts`, sem build), essa é a última barreira antes do tarball — sem ela,
60
+ um `tsc` vermelho, inventário desatualizado ou violação da política vaza pro cliente. Emergência (evite):
61
+ `pnpm release --skip-checks`.
57
62
 
58
63
  **Smoke do esqueleto**: depois do bump e antes do publish, o `release.sh` gera um app com
59
64
  `opus create`, instala o **tarball exato** que vai ser publicado e roda os gates dele
60
- (typecheck · test · `opus check` · manifest · build). É o que pega o que typecheck+test não
65
+ (typecheck · test · `opus check` · `opus copy --check` · `base copy check` · manifest ·
66
+ build). O template exige `@softize/base ^2.0.0`; publique a Base compatível antes do Opus.
67
+ O `minimumReleaseAgeExclude` do template inclui os dois pacotes, e o smoke executa os
68
+ fragmentos de pre-push para provar que o layout pnpm instalado resolve ambos os CLIs.
69
+ É o que pega o que typecheck+test não
61
70
  alcançam — `files` mal recortado, export que só resolve por caminho relativo, template
62
71
  pinado numa versão que ainda não existe no registry. Ele vivia só no `release.yml`; como as
63
72
  últimas releases saíram pela estação (sem tag), passou tempo sem rodar, e foi assim que o
64
73
  template chegou a quebrar o `tsc` de um app recém-criado. `--skip-checks` pula junto.
65
74
 
66
75
  O script bumpa, publica **e leva o bump pra `origin/main`** — não sobra passo manual. Em
67
- `--local` ele não commita nada (Verdaccio é sandbox), e com `pnpm release none` não bump
68
- pra commitar.
76
+ `--local` ele não commita nada (Verdaccio é sandbox). `pnpm release none` não cria diff de
77
+ versão, mas ainda commita qualquer derivado que a materialização legitimamente atualizar.
78
+ `--dry-run` encerra depois das mesmas materializações/gates/smoke, sem publish, commit ou
79
+ push; use `none` para validar a versão atual sem sujar o manifesto.
69
80
 
70
81
  ## Verificar
71
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "11.1.1",
3
+ "version": "12.0.1",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,9 @@
14
14
  "./check": {
15
15
  "default": "./bin/lib/check.mjs"
16
16
  },
17
+ "./copy": {
18
+ "default": "./bin/lib/copy.mjs"
19
+ },
17
20
  "./introspect": {
18
21
  "default": "./bin/lib/introspect.mjs"
19
22
  },
@@ -198,6 +201,7 @@
198
201
  },
199
202
  "dependencies": {
200
203
  "@modelcontextprotocol/sdk": "^1.29.0",
204
+ "@softize/base": "^2.0.0",
201
205
  "@radix-ui/react-checkbox": "^1.1.3",
202
206
  "@radix-ui/react-dialog": "^1.1.4",
203
207
  "@radix-ui/react-dropdown-menu": "^2.1.4",
@@ -345,6 +349,7 @@
345
349
  "homepage": "https://opus.softize.com.br",
346
350
  "scripts": {
347
351
  "postinstall": "node ./bin/lib/postinstall.mjs",
352
+ "copy:check": "node ./bin/cli.mjs copy --check",
348
353
  "typecheck": "tsc --noEmit",
349
354
  "test": "vitest run",
350
355
  "test:watch": "vitest",
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
3
+ echo "opus copy pre-push: não foi possível localizar a raiz Git." >&2
4
+ exit 1
5
+ }
6
+ runner="$repo_root/.githooks/run-opus-pre-push.mjs"
7
+ if [ ! -f "$runner" ]; then
8
+ echo "opus copy pre-push: runner gerenciado ausente; rode opus setup." >&2
9
+ exit 1
10
+ fi
11
+
12
+ # O prefixo 00 põe a verificação de freshness antes do fragmento `base`, que
13
+ # valida o conteúdo. --check é somente leitura: o push nunca suja o worktree.
14
+ node "$runner" "$repo_root" copy
@@ -1,23 +1,9 @@
1
1
  #!/bin/sh
2
- if [ -f node_modules/@softize/opus/bin/cli.mjs ]; then
3
- opus_cli=node_modules/@softize/opus/bin/cli.mjs
4
- elif [ -f packages/opus/bin/cli.mjs ]; then
5
- opus_cli=packages/opus/bin/cli.mjs
6
- else
7
- echo "opus pre-push: @softize/opus não está instalado." >&2
2
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
3
+ echo "opus pre-push: não foi possível localizar a raiz Git." >&2
8
4
  exit 1
9
- fi
10
-
11
- node "$opus_cli" pre-push materialization || exit $?
12
-
13
- # No repositório-fonte do próprio Opus, os exemplos deliberadamente inválidos dos testes
14
- # exercitam o linter. A suíte é o gate dessa árvore; não escaneie fixtures como consumidor.
15
- [ -f packages/opus/bin/cli.mjs ] && exit 0
16
-
17
- # A raiz de um monorepo não precisa ser um app. Cada marcador per-app define um alvo
18
- # real do check; node_modules e .git nunca participam da descoberta.
19
- find . -name opus.json -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.maestro/*' -exec dirname {} \; |
20
- sort -u |
21
- while IFS= read -r app_dir; do
22
- node "$opus_cli" check "$app_dir" || exit $?
23
- done
5
+ }
6
+ runner="$repo_root/.githooks/run-opus-pre-push.mjs"
7
+ [ -f "$runner" ] || { echo "opus pre-push: runner gerenciado ausente; rode opus setup." >&2; exit 1; }
8
+ node "$runner" "$repo_root" materialization || exit $?
9
+ node "$runner" "$repo_root" apps
@@ -0,0 +1,141 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path'
5
+
6
+ const PACKAGE_NAME = '@softize/opus'
7
+ const SKIP = new Set(['node_modules', '.git', '.maestro', 'dist', 'build', 'coverage', '.next'])
8
+
9
+ function fail(message) { throw new Error(message) }
10
+ function inside(root, candidate) {
11
+ const local = relative(root, candidate)
12
+ return local !== '..' && !local.startsWith(`..${sep}`) && !isAbsolute(local)
13
+ }
14
+ function identity(stat) { return `${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.ctimeNs}` }
15
+ function canonicalRelativeDirectory(value) {
16
+ if (typeof value !== 'string' || value === '' || value.trim() !== value) return false
17
+ if (value !== value.normalize('NFC') || value.includes('\\') || /[\u0000-\u001f\u007f]/u.test(value)) return false
18
+ if (value === '.') return true
19
+ if (posix.isAbsolute(value) || win32.isAbsolute(value) || /^[A-Za-z]:/u.test(value)) return false
20
+ if (value === '..' || posix.normalize(value) !== value) return false
21
+ return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..')
22
+ }
23
+ function stableDirectory(root, value) {
24
+ let cursor = root
25
+ for (const component of value === '.' ? [] : value.split('/')) {
26
+ cursor = join(cursor, component)
27
+ const before = lstatSync(cursor, { bigint: true })
28
+ if (before.isSymbolicLink() || !before.isDirectory()) fail('resolveFrom deve apontar para diretório regular interno.')
29
+ const canonical = realpathSync(cursor)
30
+ const after = lstatSync(cursor, { bigint: true })
31
+ if (identity(before) !== identity(after)) fail('resolveFrom mudou durante a resolução do hook.')
32
+ if (!inside(root, canonical)) fail('resolveFrom sai da raiz do projeto.')
33
+ }
34
+ return cursor
35
+ }
36
+ function stableConfig(root) {
37
+ const path = join(root, 'base.json')
38
+ let descriptor
39
+ try {
40
+ const before = lstatSync(path, { bigint: true })
41
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink > 1n) fail('base.json deve ser arquivo regular sem links.')
42
+ let flags = constants.O_RDONLY
43
+ if (constants.O_NOFOLLOW !== undefined) flags |= constants.O_NOFOLLOW
44
+ descriptor = openSync(path, flags)
45
+ const opened = fstatSync(descriptor, { bigint: true })
46
+ if (opened.dev !== before.dev || opened.ino !== before.ino || identity(opened) !== identity(before)) fail('base.json mudou durante a resolução do hook.')
47
+ const content = readFileSync(descriptor, 'utf8')
48
+ const after = lstatSync(path, { bigint: true })
49
+ if (after.isSymbolicLink() || identity(after) !== identity(opened)) fail('base.json mudou durante a resolução do hook.')
50
+ try { return JSON.parse(content) } catch { fail('base.json não é JSON válido.') }
51
+ } catch (error) {
52
+ if (error instanceof Error && error.message.startsWith('base.json')) throw error
53
+ fail('base.json ausente ou inacessível.')
54
+ } finally {
55
+ if (descriptor !== undefined) try { closeSync(descriptor) } catch { /* já falhou */ }
56
+ }
57
+ }
58
+ function directRuntime(root, directory, expectedVersion) {
59
+ try {
60
+ const packagePath = join(directory, 'package.json')
61
+ const cliPath = join(directory, 'bin/cli.mjs')
62
+ const packageStat = lstatSync(packagePath, { bigint: true })
63
+ const cliStat = lstatSync(cliPath, { bigint: true })
64
+ if (packageStat.isSymbolicLink() || !packageStat.isFile() || packageStat.nlink > 1n) return null
65
+ if (cliStat.isSymbolicLink() || !cliStat.isFile() || cliStat.nlink > 1n) return null
66
+ const metadata = JSON.parse(readFileSync(packagePath, 'utf8'))
67
+ const cli = realpathSync(cliPath)
68
+ return metadata.name === PACKAGE_NAME && metadata.version === expectedVersion && inside(root, cli) ? cli : null
69
+ } catch { return null }
70
+ }
71
+ function packageRuntime(root, packageConfig) {
72
+ const resolutionBase = stableDirectory(root, packageConfig.resolveFrom)
73
+ const direct = directRuntime(root, resolutionBase, packageConfig.version)
74
+ if (direct !== null) return direct
75
+ const requireFrom = createRequire(join(resolutionBase, 'package.json'))
76
+ let packageJson
77
+ try { packageJson = realpathSync(requireFrom.resolve(`${PACKAGE_NAME}/package.json`)) }
78
+ catch { fail(`${PACKAGE_NAME} não foi resolvido a partir de ${packageConfig.resolveFrom}.`) }
79
+ if (!inside(root, packageJson)) fail(`${PACKAGE_NAME} resolveu fora da raiz do projeto.`)
80
+ const installed = JSON.parse(readFileSync(packageJson, 'utf8'))
81
+ if (installed.name !== PACKAGE_NAME || installed.version !== packageConfig.version) {
82
+ fail(`${PACKAGE_NAME} resolvido não corresponde à versão ${packageConfig.version} aplicada.`)
83
+ }
84
+ const cli = realpathSync(resolve(packageJson, '../bin/cli.mjs'))
85
+ if (!inside(root, cli) || !statSync(cli).isFile()) fail(`CLI de ${PACKAGE_NAME} é inválida ou externa ao projeto.`)
86
+ return cli
87
+ }
88
+ function run(cli, root, args, capture = false) {
89
+ const result = spawnSync(process.execPath, [cli, ...args], {
90
+ cwd: root,
91
+ encoding: capture ? 'utf8' : undefined,
92
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
93
+ })
94
+ if (result.error !== undefined) fail(`não foi possível executar ${PACKAGE_NAME}: ${result.error.message}`)
95
+ if (result.status === null) fail(`execução de ${PACKAGE_NAME} foi interrompida.`)
96
+ return result
97
+ }
98
+ function apps(root, dir = root, depth = 0) {
99
+ if (depth > 8) return []
100
+ const found = statSync(dir).isDirectory() && (() => { try { return statSync(join(dir, 'opus.json')).isFile() } catch { return false } })() ? [dir] : []
101
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
102
+ if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || SKIP.has(entry.name)) continue
103
+ found.push(...apps(root, join(dir, entry.name), depth + 1))
104
+ }
105
+ return found
106
+ }
107
+ function main() {
108
+ try {
109
+ const requestedRoot = process.argv[2]
110
+ const mode = process.argv[3]
111
+ if (requestedRoot === undefined) fail('raiz do projeto não informada pelo hook.')
112
+ const root = realpathSync(resolve(requestedRoot))
113
+ if (!statSync(root).isDirectory()) fail('raiz do projeto não é um diretório.')
114
+ const config = stableConfig(root)
115
+ const packageConfig = config?.packages?.[PACKAGE_NAME]
116
+ if (packageConfig === null || typeof packageConfig !== 'object') fail(`configuração de ${PACKAGE_NAME} ausente em base.json.`)
117
+ if (typeof packageConfig.version !== 'string' || packageConfig.version === '') fail(`version de ${PACKAGE_NAME} ausente em base.json.`)
118
+ if (!canonicalRelativeDirectory(packageConfig.resolveFrom)) fail(`resolveFrom de ${PACKAGE_NAME} deve ser diretório relativo canônico.`)
119
+ const cli = packageRuntime(root, packageConfig)
120
+ if (mode === 'copy') process.exitCode = run(cli, root, ['copy', root, '--check']).status
121
+ else if (mode === 'materialization') process.exitCode = run(cli, root, ['pre-push', 'materialization']).status
122
+ else if (mode === 'check') {
123
+ const app = realpathSync(resolve(process.argv[4] ?? ''))
124
+ if (!inside(root, app) || !statSync(app).isDirectory()) fail('app do check é inválido ou externo ao projeto.')
125
+ const result = run(cli, app, ['check'], true)
126
+ process.stdout.write(result.stdout ?? '')
127
+ process.stderr.write(result.stderr ?? '')
128
+ process.exitCode = result.status
129
+ } else if (mode === 'apps') {
130
+ if (directRuntime(root, stableDirectory(root, packageConfig.resolveFrom), packageConfig.version) !== null) return
131
+ for (const app of [...new Set(apps(root))].sort()) {
132
+ const result = run(cli, app, ['check'])
133
+ if (result.status !== 0) { process.exitCode = result.status; return }
134
+ }
135
+ } else fail('operação do hook inválida.')
136
+ } catch (error) {
137
+ console.error(`opus pre-push: ${error instanceof Error ? error.message : 'falha ao resolver a CLI.'}`)
138
+ process.exitCode = 1
139
+ }
140
+ }
141
+ main()
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Hook Stop da base Opus: quando o turno termina, roda o `opus check` dos apps que a
2
+ * Hook Stop da camada Opus: quando o turno termina, roda o `opus check` dos apps que a
3
3
  * sessão MEXEU (arquivos alterados no git) e BLOQUEIA o fim do turno se houver violação
4
4
  * — exit 2 devolve os findings ao agente, que se corrige na hora. O gate da casa deixa
5
5
  * de depender de alguém lembrar de rodar.
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { execFileSync } from 'node:child_process'
13
13
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
- import { dirname, join } from 'node:path'
14
+ import { join } from 'node:path'
15
15
 
16
16
  let event = {}
17
17
  try {
@@ -58,44 +58,26 @@ function findApps(dir, depth) {
58
58
 
59
59
  // Só os apps que a sessão tocou (app mais específico primeiro, pra um app aninhado
60
60
  // não cair no ancestral).
61
- const apps = findApps(root, 3).sort((a, b) => b.length - a.length)
61
+ const apps = findApps(root, 8).sort((a, b) => b.length - a.length)
62
62
  const touched = new Set()
63
63
  for (const rel of changed) {
64
- const app = apps.find((a) => a === root || join(root, rel).startsWith(`${a}/`))
64
+ const changedPath = join(root, rel.replace(/\/$/u, ''))
65
+ const app = apps.find((a) =>
66
+ a === root || changedPath === a || changedPath.startsWith(`${a}/`) || a.startsWith(`${changedPath}/`),
67
+ )
65
68
  if (app !== undefined) touched.add(app)
66
69
  }
67
70
 
68
- /**
69
- * Bin do opus pro app: sobe do app até a raiz (repo simples / hoisted) e, se não achar,
70
- * desce UM nível (app composto — pacotes api/web dentro do app, o pnpm instala o bin
71
- * no node_modules de cada pacote). Sem bin → fail-open (o app fica de fora).
72
- */
73
- function opusBinFor(appDir) {
74
- let dir = appDir
75
- for (;;) {
76
- const bin = join(dir, 'node_modules', '.bin', 'opus')
77
- if (existsSync(bin)) return bin
78
- if (dir === root || dirname(dir) === dir) break
79
- dir = dirname(dir)
80
- }
81
- try {
82
- for (const e of readdirSync(appDir, { withFileTypes: true })) {
83
- if (!e.isDirectory() || e.name.startsWith('.') || SKIP.has(e.name)) continue
84
- const bin = join(appDir, e.name, 'node_modules', '.bin', 'opus')
85
- if (existsSync(bin)) return bin
86
- }
87
- } catch {
88
- /* Ilegível → sem bin. */
89
- }
90
- return null
91
- }
71
+ // O mesmo runner versionado do pre-push resolve o pacote estritamente a partir do
72
+ // workspace registrado em base.json. Assim um app composto não cai em bin hoisted,
73
+ // global ou homônimo de outro workspace.
74
+ const runner = join(root, '.githooks', 'run-opus-pre-push.mjs')
75
+ if (!existsSync(runner)) process.exit(0)
92
76
 
93
77
  const failures = []
94
78
  for (const app of touched) {
95
- const bin = opusBinFor(app)
96
- if (bin === null) continue
97
79
  try {
98
- execFileSync(bin, ['check'], { cwd: app, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 45_000 })
80
+ execFileSync(process.execPath, [runner, root, 'check', app], { cwd: app, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 45_000 })
99
81
  } catch (err) {
100
82
  // Sem cor: o destinatário é o agente, não um terminal.
101
83
  const out = `${err.stdout ?? ''}${err.stderr ?? ''}`.replace(/\x1b\[[0-9;]*m/g, '')
@@ -1,5 +1,8 @@
1
1
  # Opus
2
2
 
3
+ <!-- softize-skill-route: $implement-opus-change -->
4
+ <!-- softize-skill-route: $upgrade-opus -->
5
+
3
6
  Este repo usa `@softize/opus`, um SDK e protocolo de actions. A versão aplicada aos
4
7
  artefatos de agentes fica em `base.json`; cada app Opus mantém seu marcador `opus.json`.
5
8
 
@@ -10,11 +13,14 @@ artefatos de agentes fica em `base.json`; cada app Opus mantém seu marcador `op
10
13
  - Manter contrato compartilhável separado de banco, segredo e driver server-only; usar
11
14
  `defineContract` com `bindAction` quando cliente e servidor consomem a mesma action.
12
15
  - Não duplicar schemas, tipos de transporte, validação ou fetch que o contrato já fornece.
13
- - Rodar `opus check` e os gates do projeto depois de alterar actions, bindings ou versão do SDK.
16
+ - Regenerar o inventário com `opus copy` quando mudar copy em contrato ou componente Opus
17
+ mapeado; o Opus classifica os papéis semânticos e a política universal de `@softize/base`
18
+ valida o conteúdo.
19
+ - Rodar `opus check`, `opus copy --check` e os gates do projeto depois de alterar actions,
20
+ bindings ou versão do SDK.
14
21
  - Quando a default branch tiver adotado uma versão diferente da worktree, reconciliar a
15
- branch antes de commit/review e usar `upgrade-opus` para ler changelog e aplicar migrações.
22
+ branch antes de commit/review e carregar `$upgrade-opus` para ler changelog e aplicar migrações.
23
+ - Ao implementar mudança que atravesse domínio, action, runtime ou integração Opus, carregar
24
+ `$implement-opus-change`; seguir as rotas especializadas que ela indicar.
16
25
  - Consultar as skills Opus materializadas conforme o workflow; não atribuir ao SDK decisões
17
26
  universais de domínio ou arquitetura.
18
- - Usar `write-product-communication` ao criar ou alterar documentação, textos de interface,
19
- mensagens de erro, CLI, onboarding ou explicações. Partir da situação do leitor, apresentar
20
- contexto antes da regra e evitar slogans, absolutos e linguagem de manifesto.
@@ -26,13 +26,14 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
26
26
  7. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
27
27
  `rounded-sm` a `rounded-xl` já expressam a forma.
28
28
  8. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
29
- 9. Usar `write-product-communication` para labels, ajuda, estados vazios, confirmações e erros;
30
- revisar o fluxo completo, não apenas cada string isolada.
31
29
 
32
30
  ## Verificação
33
31
 
34
- Rodar `opus check`, testes focados, typecheck e build da superfície. Inspecionar visualmente
35
- a rota real e validar navegação por URL quando aplicável.
32
+ Rodar `opus check`, testes focados, typecheck e build da superfície. Se a interface alterou
33
+ copy em contrato ou componente Opus mapeado, regenerar com `opus copy` e executar
34
+ `base copy check`. Copy dinâmica numa superfície mapeada precisa de fallback estático ou de
35
+ uma decisão explícita de arquitetura; não contorne o diagnóstico com `aria-label` paralelo.
36
+ Inspecionar visualmente a rota real e validar navegação por URL quando aplicável.
36
37
 
37
38
  ## Limites
38
39