@softize/opus 11.1.1 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -1
- package/README.md +31 -1
- package/bin/cli.mjs +97 -27
- package/bin/lib/check.mjs +55 -43
- package/bin/lib/copy.mjs +2202 -0
- package/bin/lib/create.mjs +227 -39
- package/bin/lib/db-migrate-runner.mjs +9 -6
- package/bin/lib/db-project-path.mjs +20 -0
- package/bin/lib/db-scaffold-runner.mjs +23 -8
- package/bin/lib/db.mjs +6 -4
- package/bin/lib/gen.mjs +60 -29
- package/bin/lib/init.mjs +212 -56
- package/bin/lib/introspect.mjs +3 -2
- package/bin/lib/materialize.mjs +623 -97
- package/bin/lib/postinstall.mjs +6 -5
- package/bin/lib/validate-skill.mjs +502 -30
- package/docs/code-style.md +142 -7
- package/docs/consumer-upgrade-propagation.md +4 -3
- package/docs/releasing.md +28 -17
- package/package.json +6 -1
- package/registry/git/pre-push.d/00-opus-copy +14 -0
- package/registry/git/pre-push.d/opus +7 -21
- package/registry/git/run-opus-pre-push.mjs +141 -0
- package/registry/hooks/opus-check-on-stop.mjs +13 -31
- package/registry/instructions/opus.md +11 -5
- package/registry/skills/build-opus-ui/SKILL.md +5 -4
- package/registry/skills/create-opus-action/SKILL.md +4 -4
- package/registry/skills/implement-opus-change/SKILL.md +7 -5
- package/registry/skills/upgrade-opus/SKILL.md +8 -4
- package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
- package/registry/templates/app/package.json +4 -0
- package/registry/templates/app/pnpm-workspace.yaml +3 -2
- package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
- package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
- package/src/ui/docs/content/cli.md +8 -7
- package/src/ui/docs/content/communication.md +79 -126
- package/src/ui/docs/content/getting-started.md +29 -16
- package/registry/skills/write-product-communication/SKILL.md +0 -28
- package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
- package/registry/templates/app/_npmrc +0 -1
- package/registry/templates/monorepo/_npmrc +0 -1
package/docs/code-style.md
CHANGED
|
@@ -5,12 +5,147 @@ order: 5
|
|
|
5
5
|
|
|
6
6
|
# Code style — padrão softize
|
|
7
7
|
|
|
8
|
-
Este arquivo registra
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
campos do spec, registro no runtime
|
|
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
|
|
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 `
|
|
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
|
|
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`).
|
|
9
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 só 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
|
|
54
|
-
+ `pnpm test`
|
|
55
|
-
|
|
56
|
-
|
|
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` ·
|
|
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)
|
|
68
|
-
|
|
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": "
|
|
3
|
+
"version": "12.0.0",
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
35
|
-
|
|
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
|
|