@softize/opus 25.1.0 → 25.3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/assistant-schema.d.mts +55 -0
  4. package/bin/lib/assistant-schema.mjs +286 -0
  5. package/bin/lib/check.mjs +173 -0
  6. package/bin/lib/copy.mjs +2 -0
  7. package/bin/lib/gen-manifest.mjs +4 -0
  8. package/bin/lib/gen-runner.mjs +31 -2
  9. package/bin/lib/gen-stubs.mjs +3 -0
  10. package/bin/lib/mcp.mjs +1 -1
  11. package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
  12. package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +82 -0
  13. package/docs/protocol.md +59 -1
  14. package/package.json +5 -1
  15. package/registry/skills/create-opus-action/SKILL.md +8 -3
  16. package/registry/skills/create-opus-action/references/contract-and-binding.md +24 -1
  17. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  18. package/registry/skills/test-opus-action/SKILL.md +4 -1
  19. package/registry/skills/test-opus-action/references/evaluations.md +2 -0
  20. package/registry/skills/test-opus-action/references/harness.md +14 -0
  21. package/src/ai/index.ts +3 -0
  22. package/src/ai/resource-context.ts +113 -0
  23. package/src/core/actions.ts +5 -2
  24. package/src/core/assistant.ts +545 -0
  25. package/src/core/contracts.ts +2 -0
  26. package/src/core/index.ts +23 -0
  27. package/src/core/list-grouping.ts +45 -0
  28. package/src/core/runtime.ts +138 -0
  29. package/src/core/types.ts +39 -0
  30. package/src/mcp/index.ts +3 -0
  31. package/src/ui/components/patterns/grouped-list.ts +88 -0
  32. package/src/ui/components/patterns/list.tsx +395 -61
  33. package/src/ui/docs/content/action-list.md +42 -9
  34. package/src/ui/docs/content/actions.md +97 -1
  35. package/src/ui/docs/content/ai.md +4 -2
  36. package/src/ui/docs/content/cli.md +2 -1
  37. package/src/ui/docs/content/mcp.md +3 -1
  38. package/src/ui/drivers/context.tsx +50 -0
  39. package/src/ui/drivers/react.tsx +9 -69
  40. package/src/ui/meta.ts +1 -1
@@ -55,6 +55,9 @@ function shapeAction(a) {
55
55
  automatable: a.automatable,
56
56
  effect: a.effect ?? null,
57
57
  personalData: a.personalData ?? null,
58
+ // ADR 0053: a publicação para a IA e, na action dona, o assistente do recurso.
59
+ ai: a.ai ?? { enabled: false, description: null, destructive: false, requiresConfirmation: false },
60
+ ...(a.assistant !== undefined && a.assistant !== null ? { assistant: a.assistant } : {}),
58
61
  internal: a.internal,
59
62
  successStatus: a.successStatus,
60
63
  input: a.input,
@@ -77,6 +80,7 @@ function shapeAction(a) {
77
80
  out.paginate = a.paginate;
78
81
  out.text = a.text;
79
82
  out.periods = a.periods;
83
+ if (a.grouping !== undefined) out.grouping = a.grouping;
80
84
  }
81
85
  if (a.kind === "view") {
82
86
  out.projection = a.projection ?? [];
@@ -43,7 +43,7 @@
43
43
  * input: JSONSchema, // zod-to-json-schema
44
44
  * output: JSONSchema, // zod-to-json-schema
45
45
  * fields?: Record<string, FieldSpecSerialized>,
46
- * filters?, sort?, paginate?, text?, periods?, // search
46
+ * filters?, sort?, paginate?, text?, periods?, grouping?, // list
47
47
  * projection?, expand?, // view
48
48
  * background?: boolean,
49
49
  * emits?: string[],
@@ -53,6 +53,8 @@
53
53
  * automatable?: boolean, // aceita execução pelo sistema (ADR 0028)
54
54
  * effect: 'read'|'write'|null, // efeito resolvido; list/view = read (ADR 0041)
55
55
  * personalData: boolean|null, // devolve dado pessoal; null = não declarado
56
+ * ai: { enabled, description, destructive, requiresConfirmation }, // ADR 0053
57
+ * assistant?: { resource, skill, label, title, context, maxContextChars }, // view dona
56
58
  * internal?: boolean,
57
59
  * rateLimit?: { window, max },
58
60
  * confirm?: ConfirmSpec,
@@ -67,6 +69,7 @@
67
69
 
68
70
  import path from "node:path";
69
71
  import { pathToFileURL, fileURLToPath } from "node:url";
72
+ import { assistantSchemaIssues, serializeAiConfig } from "./assistant-schema.mjs";
70
73
 
71
74
  const __filename = fileURLToPath(import.meta.url);
72
75
  const __dirname = path.dirname(__filename);
@@ -90,7 +93,7 @@ async function main() {
90
93
  const opusCoreUrl = pathToFileURL(
91
94
  path.join(PACKAGE_ROOT, "src", "core", "index.ts"),
92
95
  ).href;
93
- const { flattenDomain, domainPurges, declaredJsonSchema } = await import(opusCoreUrl);
96
+ const { flattenDomain, domainPurges, declaredJsonSchema, publicAssistant } = await import(opusCoreUrl);
94
97
 
95
98
  const opusSeedUrl = pathToFileURL(
96
99
  path.join(PACKAGE_ROOT, "src", "seed", "index.ts"),
@@ -138,6 +141,7 @@ async function main() {
138
141
  const ctx = {
139
142
  flattenDomain,
140
143
  domainPurges,
144
+ publicAssistant,
141
145
  // Standard Schema que declara o próprio JSON Schema (ADR 0051) dispensa o zod.
142
146
  toJsonSchema: (schema) =>
143
147
  declaredJsonSchema(schema, { target: "openapi-3.0" }) ??
@@ -393,6 +397,8 @@ function serializeAction(action, ctx) {
393
397
  // ADR 0041: `list` e `view` são leitura pelo protocolo; null = não declarado.
394
398
  effect: action.kind === "list" || action.kind === "view" ? "read" : (action.effect ?? null),
395
399
  personalData: typeof action.personalData === "boolean" ? action.personalData : null,
400
+ // ADR 0053: a publicação para a IA entra no manifest, e a action dona, o assistente.
401
+ ai: serializeAiConfig(action.ai),
396
402
  internal: action.internal === true,
397
403
  successStatus: action.successStatus,
398
404
  input: safeToJsonSchema(action.input, ctx),
@@ -427,6 +433,7 @@ function serializeAction(action, ctx) {
427
433
  base.paginate = action.paginate ?? null;
428
434
  base.text = action.text ?? null;
429
435
  base.periods = Array.isArray(action.periods) ? action.periods : [];
436
+ if (action.grouping !== undefined) base.grouping = action.grouping;
430
437
  }
431
438
 
432
439
  if (action.kind === "view") {
@@ -434,6 +441,20 @@ function serializeAction(action, ctx) {
434
441
  base.expand = action.expand ?? null;
435
442
  }
436
443
 
444
+ if (action.kind === "view" && action.assistant !== undefined && action.assistant !== null) {
445
+ const issues = assistantSchemaIssues({
446
+ assistant: action.assistant,
447
+ input: base.input,
448
+ output: base.output,
449
+ });
450
+ if (issues.length > 0) {
451
+ throw declarationError(
452
+ `O assistente da action "${action.name}" não confere com o contrato (ADR 0053): ${issues.join("; ")}.`,
453
+ );
454
+ }
455
+ base.assistant = ctx.publicAssistant(action.assistant);
456
+ }
457
+
437
458
  return base;
438
459
  }
439
460
 
@@ -605,6 +626,14 @@ function emitError(message) {
605
626
  process.exit(1);
606
627
  }
607
628
 
629
+ /** Erro de declaração do consumidor: a mensagem basta, sem a pilha do runner. */
630
+ function declarationError(message) {
631
+ const err = new Error(message);
632
+ err.name = "OpusDeclarationError";
633
+ return err;
634
+ }
635
+
608
636
  main().catch((err) => {
637
+ if (err?.name === "OpusDeclarationError") return emitError(err.message);
609
638
  emitError(`runner crash: ${err.stack ?? err.message ?? String(err)}`);
610
639
  });
@@ -149,6 +149,9 @@ function renderActionStub(action) {
149
149
  const filters = stripNullsFromFields(action.filters)
150
150
  lines.push(` filters: ${formatObjectLiteral(filters, ' ')},`)
151
151
  }
152
+ if (action.grouping && Array.isArray(action.grouping.fields)) {
153
+ lines.push(` grouping: ${JSON.stringify(action.grouping)},`)
154
+ }
152
155
  }
153
156
  if (Array.isArray(action.invalidates) && action.invalidates.length > 0) {
154
157
  lines.push(` invalidates: ${JSON.stringify(action.invalidates)},`)
package/bin/lib/mcp.mjs CHANGED
@@ -42,7 +42,7 @@ export function buildServer() {
42
42
  server.registerTool(
43
43
  'opus_check',
44
44
  {
45
- description: 'Valida as convenções das actions (régua) — defineAction e o split defineContract+bindAction: naming, kind, ordem dos campos, export, requires-sem-authorize, ignored-by-kind, soft-delete-retention. Retorna findings.',
45
+ description: 'Valida as convenções das actions (régua) — defineAction e o split defineContract+bindAction: naming, kind, ordem dos campos, export, requires-sem-authorize, ignored-by-kind, assistant-declaration, soft-delete-retention. Retorna findings.',
46
46
  inputSchema: { dir: z.string().optional() },
47
47
  },
48
48
  async ({ dir }) => asText(await scanDir(dirOf(dir))),
@@ -0,0 +1,443 @@
1
+ # ADR 0053 — A action de leitura declara o assistente do recurso
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-03.
5
+ - **Complementa:** ADR 0041 (efeito e dado pessoal no contrato) e ADR 0013 (Presentation como
6
+ artefato portátil orientado a action).
7
+ - **Relacionadas:** ADR 0111 da Grand Brasil, primeiro consumidor desta decisão; ADRs 0004, 0069,
8
+ 0087, 0103 e 0104 da Grand Brasil.
9
+
10
+ ## Contexto
11
+
12
+ Um produto feito com o Opus pode oferecer, além do chat geral, um chat especializado num recurso: a
13
+ conversa sobre um cliente, um relatório ou um fluxo. Esse chat precisa saber três coisas: como ler
14
+ o recurso e conferir se a pessoa ainda pode vê-lo, qual procedimento a IA segue ao tratar dele e
15
+ quais dados do recurso entram na conversa. Hoje nenhuma dessas três coisas está no contrato.
16
+
17
+ A Grand Brasil mostra o custo de deixá-las fora. Cada uma mora num lugar diferente, escrita à mão:
18
+
19
+ - O procedimento e a persona de cada tipo de recurso estão numa tabela no motor de IA
20
+ (`services/ai/src/resource-assistant.ts:17-61`).
21
+ - O dado que vai ao chat é montado na interface, tela por tela
22
+ (`apps/main/src/copilot/context.ts:89-139`).
23
+ - A conferência de acesso é uma rota com um `if` por tipo
24
+ (`services/main/src/server/main.ts:207-262`). Para relatórios, são duas rotas que chamam a regra
25
+ de acesso do domínio direto, sem passar pela action `report.read`
26
+ (`services/main/src/server/report-access-route.ts:22-37` e
27
+ `services/main/src/server/report-routes.ts:28-44`). A leitura do relatório por essas rotas não
28
+ gera registro de auditoria nem aparece na Lente.
29
+
30
+ O que o Opus já projeta não basta para mostrar esse desenho:
31
+
32
+ - O manifest projeta `effect` e `personalData`, mas não a configuração `ai`
33
+ (`bin/lib/gen-manifest.mjs:39-68`). Quem lê o manifest não sabe nem se a action está publicada
34
+ para a IA.
35
+ - A Lente (`@softize/lens`) lê o manifest e, por action, mostra nome, tipo, documentação,
36
+ permissão, eventos, entrada e saída (`lens/src/structure.ts:318-333`). Não mostra efeito, dado
37
+ pessoal, publicação para a IA nem assistente.
38
+ - `runtime.aiTools()` (`src/core/runtime.ts:997-1037`) e o `ListTools` do MCP
39
+ (`src/mcp/index.ts:54-60`) carregam efeito, dado pessoal e Produtos de Dados. O motor de IA da
40
+ Grand Brasil já lê esses metadados pelo MCP (`services/ai/src/mcp.ts:93-101`). É um canal pronto
41
+ para levar a declaração ao host.
42
+ - A Presentation já declara `assistant: { triggerLabel }` (`src/core/presentation.ts:125-130` e
43
+ `160`). Essa declaração cobre só a interface: o botão e a divisão da superfície. Não diz o que o
44
+ chat faz.
45
+
46
+ As forças em tensão:
47
+
48
+ - **Um lugar só.** Leitura, conferência de acesso, procedimento e dado ao modelo precisam vir da
49
+ mesma declaração, para não divergirem.
50
+ - **Contrato compartilhável.** O contrato roda no cliente e no servidor. A declaração não pode
51
+ carregar função de servidor, banco nem segredo, e precisa caber no manifest.
52
+ - **O procedimento é dado do consumidor.** Na Grand Brasil, Habilidades são registros
53
+ administráveis no banco do serviço de IA. O Opus não as conhece, e o `opus check`, que é
54
+ estático, não as alcança.
55
+ - **O dado ao modelo é mínimo, limitado e tratado como dado.** A Grand Brasil já decidiu que texto
56
+ vindo de fora entra no turno como bloco delimitado, nunca como instrução (ADR 0004 dela, seção
57
+ 3), e pôs teto no que entra no turno (seção 2 da mesma ADR). Uma projeção sem teto repetiria o
58
+ problema de custo e de diluição do prompt que motivou aquela decisão.
59
+ - **Dado pessoal.** O dado do recurso pode identificar pessoas. A marca de exposição já é do
60
+ runtime (ADR 0041) e precisa continuar valendo.
61
+ - **Fronteira do SDK.** Persona, regras de negócio e o que cada tipo de recurso significa são do
62
+ consumidor.
63
+
64
+ ## Decisão
65
+
66
+ ### 1. Uma action `view` pode declarar o assistente do recurso que ela lê
67
+
68
+ O contrato ganha o campo opcional `assistant`. A action que o declara passa a ser a **action dona**
69
+ daquele tipo de recurso: é por ela que o recurso é lido e que o acesso a ele é conferido.
70
+
71
+ ```ts
72
+ export const customerViewContract = defineContract({
73
+ name: 'customer.view',
74
+ kind: 'view',
75
+ label: 'Ver cliente',
76
+ description: 'Reúne o cadastro canônico, a qualidade dos contatos e o relacionamento comercial.',
77
+ input: z.object({ id: z.string().uuid() }),
78
+ output: customerViewSchema,
79
+ personalData: true,
80
+ requires: commercialRead,
81
+ authorize: anyPerm(...commercialRead),
82
+ ai: true,
83
+ assistant: {
84
+ resource: 'customer',
85
+ skill: 'review-customer',
86
+ label: 'Assistente do cliente',
87
+ title: 'name',
88
+ context: ['kind', 'stage', 'sources', 'leads.totalCount', 'leads.latest.status'],
89
+ },
90
+ })
91
+ ```
92
+
93
+ O exemplo deixa fora do contexto contato, documento, data de nascimento e endereço. O nome já vai
94
+ como `title` e não se repete em `context`.
95
+
96
+ | Campo | O que declara |
97
+ | --- | --- |
98
+ | `resource` | O tipo de recurso de que a action é dona. Identificador em camelCase, único no runtime. |
99
+ | `skill` | A chave do procedimento que a IA segue ao tratar desse recurso, no registro do consumidor. |
100
+ | `label` | O nome do assistente para pessoas. Entra no inventário do `opus copy`. |
101
+ | `title` | O caminho, na saída, do campo que nomeia o recurso para pessoas. |
102
+ | `context` | Os caminhos, na saída, dos campos que o host pode entregar ao modelo como dado do recurso. |
103
+ | `maxContextChars` | Opcional. Teto do dado serializado: padrão de 4.000 caracteres, mínimo de 1.000 e máximo de 16.000. |
104
+
105
+ Regras da declaração:
106
+
107
+ - Só uma action `view` declara assistente. A leitura de um recurso identificado é o papel da `view`.
108
+ - A action precisa estar publicada para a IA (`ai` habilitado). Um assistente cujo recurso a IA
109
+ não pode ler seria uma contradição.
110
+ - A action precisa declarar `personalData`, `true` ou `false`. Com assistente, a declaração deixa
111
+ de ser opcional, porque decide como o host trata o dado que entrega ao modelo.
112
+ - A entrada é um objeto em que `id`, do tipo texto, é o único campo obrigatório; outros campos, se
113
+ houver, têm valor padrão. É só com `{ id }` que o runtime pede o recurso.
114
+ - Os caminhos usam ponto para descer em objetos (`leads.totalCount`). Um caminho que passa por uma
115
+ lista se aplica a cada item. Um caminho pode atravessar um campo anulável, como `leads` em
116
+ `customer.view`: quando o valor intermediário é `null`, a projeção entrega `null` naquele ponto.
117
+ - Cada `resource` tem uma única action dona no runtime.
118
+
119
+ `assistant` fica no grupo de integração da ordem canônica, o mesmo de `ai`. O `opus check` confere
120
+ o grupo, não a posição dentro dele (`bin/lib/check.mjs:48-77`).
121
+
122
+ ### 2. O runtime lê o recurso pela action dona
123
+
124
+ O runtime ganha duas leituras:
125
+
126
+ - **`runtime.assistants()`** devolve a lista das declarações registradas, com a action dona de
127
+ cada uma. Serve ao consumidor para montar rotas e telas a partir da declaração, sem lista
128
+ paralela, e para conferir as chaves de procedimento onde também tiver o registro (seção 5).
129
+ - **`runtime.readResource(resource, id, base)`** executa a action dona com `{ id }` pelo mesmo
130
+ `execute` de qualquer chamada. A autorização, a auditoria, a marca de dado pessoal e o span são
131
+ os da action. Em caso de sucesso, devolve:
132
+
133
+ ```ts
134
+ {
135
+ ok: true,
136
+ resource: { kind: 'customer', id, title: 'Maria Souza', label: 'Assistente do cliente', skill: 'review-customer' },
137
+ context: { data: { ... }, truncated: false, omitted: [], personalData: true },
138
+ }
139
+ ```
140
+
141
+ Em caso de falha, devolve o erro da action, com a categoria dela. Um tipo de recurso sem action
142
+ dona devolve `runtime.assistant_resource_unknown`, sem executar nada.
143
+
144
+ Não há outro caminho no SDK para ler o recurso de um assistente. A declaração não exporta regra de
145
+ acesso, consulta nem atalho que dispense a action.
146
+
147
+ ### 3. O contexto é uma projeção limitada, entregue como dado
148
+
149
+ `context.data` contém somente os caminhos declarados, com a mesma estrutura da saída. O que não foi
150
+ declarado não sai.
151
+
152
+ Quando o JSON serializado passa de `maxContextChars`, o runtime reduz a projeção por etapas, até
153
+ caber, e nunca em silêncio:
154
+
155
+ 1. Remove o último item da lista que tem mais itens, e repete. Registra em `omitted` o caminho e
156
+ quantos itens saíram.
157
+ 2. Com as listas vazias, encurta pela metade o texto mais longo, até o mínimo de 200 caracteres, e
158
+ repete. Registra o caminho em `omitted`.
159
+ 3. Se ainda não couber, remove campos inteiros, do último declarado em `context` para o primeiro,
160
+ e registra cada um em `omitted`.
161
+ 4. Marca `truncated: true`.
162
+
163
+ O teto conta só os dados de `context`. O tipo, o id e o título vão no bloco fora dessa conta, e o
164
+ título é sempre encurtado a no máximo 200 caracteres, com `…` e registro em `omitted`. Cada etapa
165
+ remove alguma coisa, e a terceira chega, no limite, a um objeto vazio, que cabe em qualquer teto
166
+ válido. Por isso o corte sempre termina.
167
+
168
+ Um caminho declarado pode faltar na saída em execução, porque o runtime só valida a saída em
169
+ desenvolvimento (`src/core/runtime.ts:907-912`). Nesse caso, a projeção omite o caminho, registra-o
170
+ em `omitted` com o motivo `missing`, emite um aviso no log e não falha a leitura.
171
+
172
+ A projeção é uma função pura do core, `projectAssistantContext(assistant, output)`, usada por
173
+ `readResource` e exportada. Um host que recebe a saída da action por outro canal, como o MCP,
174
+ aplica a mesma projeção sem reimplementá-la.
175
+
176
+ O host entrega a projeção ao modelo como bloco de dado delimitado, numa mensagem de dado, e nunca
177
+ no prompt de sistema. O bloco leva o tipo, o id, o título, os dados e, quando houver corte,
178
+ `truncated` e `omitted`: o modelo precisa saber que o dado está incompleto para decidir chamar a
179
+ action. Para hosts que usam o driver de IA do Opus, o pacote oferece a função que monta o bloco e
180
+ neutraliza o delimitador dentro dos valores. O host pode usar o próprio formato, desde que cumpra a
181
+ mesma regra.
182
+
183
+ A projeção governa só o que o host entrega **por iniciativa própria**, como o dado do recurso que
184
+ acompanha um turno de conversa vinculada. Quando e com que frequência entregar é decisão do host.
185
+ A recomendação do Opus é reaproveitar a execução que já confere o acesso, em vez de executar a
186
+ action de novo só para obter o contexto: cada execução de uma action com dado pessoal é uma marca
187
+ de exposição. Quando o modelo chama a action dona como ferramenta, recebe a saída que o
188
+ contrato declara, como em qualquer chamada. Filtrar a resposta pelo consumidor faria a mesma action
189
+ devolver coisas diferentes para a tela e para a IA. Um campo que não deve chegar ao modelo em
190
+ nenhuma hipótese pede outra decisão no contrato, e não uma projeção.
191
+
192
+ **Dado pessoal.** A execução da action dona é marcada no registro da requisição como qualquer
193
+ outra (ADR 0041). `context.personalData` repete a declaração da action para que o host marque a
194
+ conversa: o Opus não conhece conversas.
195
+
196
+ ### 4. Projeções: manifest, ferramentas, MCP e Lente
197
+
198
+ - **Manifest.** Cada action passa a projetar `ai` normalizado (`enabled`, `description`,
199
+ `destructive`, `requiresConfirmation`) e, quando houver, `assistant` com todos os campos. A
200
+ publicação para a IA entra no manifest junto, porque sem ela a Lente não consegue dizer o que a
201
+ IA alcança.
202
+ - **`runtime.aiTools()`.** A ferramenta da action dona leva `metadata.assistant` com `resource`,
203
+ `skill`, `label`, `title`, `context` e `maxContextChars`. *Nota (2026-10-03):* o teto, já
204
+ resolvido, entrou na implementação para o host que projeta pelo MCP usar o mesmo valor; ver a
205
+ nota de implementação na seção 10.
206
+ - **MCP.** O `ListTools` acrescenta `com.softize.opus/assistant` em `_meta`, com o mesmo conteúdo.
207
+ O host de IA conhece o assistente pelo catálogo que já consulta, sem configuração própria.
208
+ - **Lente.** A estrutura de cada action passa a carregar `ai`, `effect`, `personalData` e
209
+ `assistant`. A página da action mostra se ela está publicada para a IA, se é dona de um tipo de
210
+ recurso, que procedimento o assistente segue e quais campos vão ao modelo. A mudança é feita no
211
+ repositório da Lente, com versão própria, lendo o manifest como hoje.
212
+
213
+ ### 5. O procedimento é referência por chave, conferida pelo consumidor
214
+
215
+ `skill` é uma chave opaca para o Opus, em minúsculas com hífen (`review-customer`). O `opus check`
216
+ confere a forma e a presença; não confere se o procedimento existe.
217
+
218
+ Quem confere é o consumidor, onde tiver acesso às duas fontes: num teste que compare as
219
+ declarações (o manifest versionado ou `runtime.assistants()`) com os procedimentos que o consumidor
220
+ versiona, ou no turno, quando o host já tem o catálogo e o registro em mãos. A subida de um serviço
221
+ não é um bom lugar: as duas fontes podem estar em serviços diferentes, e o catálogo pode exigir
222
+ credencial. Na Grand Brasil, as declarações vivem no serviço principal, as Habilidades no serviço
223
+ de IA, e o MCP interno só lista ferramentas com sessão e credencial de turno
224
+ (`packages/agent-authority/src/staff-mcp.ts:95-109` dela).
225
+
226
+ A Lente mostra a chave declarada. Mostrar se a chave resolve exige que o host informe esse estado
227
+ à Lente, o que fica fora desta decisão.
228
+
229
+ ### 6. Acesso a recurso só por action
230
+
231
+ Para o Opus, o princípio é: o recurso de um assistente é lido, e o acesso a ele é conferido,
232
+ somente pela action dona. O SDK cumpre a sua parte nas seções 2 e 3.
233
+
234
+ O Opus não enxerga as rotas HTTP que o consumidor registra por conta própria. O teste que impede
235
+ uma rota de conferir acesso sem action é do consumidor. O `docs/protocol.md` registra o princípio e
236
+ recomenda esse teste.
237
+
238
+ ### 7. O que o `opus check`, o `opus gen` e o `opus copy` cobram
239
+
240
+ A regra `assistant-declaration` do `opus check` reprova:
241
+
242
+ - `assistant` em action que não é `view`;
243
+ - `assistant` com `ai: false` ou sem `ai`;
244
+ - `assistant` sem `personalData` declarado;
245
+ - `resource` fora de camelCase ou `skill` fora de minúsculas com hífen;
246
+ - `label`, `title` ou `context` ausentes;
247
+ - o mesmo `resource` em dois contratos, pelo valor literal, entre arquivos.
248
+
249
+ O `opus gen`, que já converte a saída em JSON Schema (`bin/lib/gen-runner.mjs:399`), recusa gerar
250
+ o manifest quando `title` ou um caminho de `context` não existem na saída, ou quando a entrada não
251
+ tem `id` de texto como único campo obrigatório. Num campo anulável ou numa união (`anyOf` no JSON
252
+ Schema), o caminho vale se existir em algum ramo que não seja `null`.
253
+
254
+ O `register` do runtime recusa duas actions donas do mesmo `resource`, com
255
+ `runtime.assistant_duplicate_resource`, e um `maxContextChars` fora do intervalo, com
256
+ `runtime.assistant_context_limit_invalid`. Os erros desta decisão usam o prefixo
257
+ `runtime.assistant_`.
258
+
259
+ O `opus copy` passa a ler `assistant.label`.
260
+
261
+ ### 8. Relação com a Presentation
262
+
263
+ `assistant.triggerLabel` da Presentation continua sendo a capacidade de interface: o botão no
264
+ header e a divisão da superfície. A declaração da action é o comportamento do chat. As duas são
265
+ independentes nesta decisão: o assistente existe também onde não há Presentation, como num drawer
266
+ composto à mão ou no chat geral.
267
+
268
+ ### 9. O que fica fora
269
+
270
+ - Endereço de navegação do recurso. É da interface do consumidor.
271
+ - Persona, regras próprias de cada tipo e a forma de ativar o procedimento no turno. São do host.
272
+ - Escrita. A declaração descreve a leitura; a escrita continua governada por `effect`, `ai` e pelo
273
+ host.
274
+
275
+ ### 10. As questões que ficaram para decisão
276
+
277
+ A proposta deixou cinco questões para decidir antes da implementação. Em 2026-10-03, todas foram
278
+ decididas como a proposta recomendava; cada item registra a questão, a decisão e o motivo.
279
+
280
+ 1. **Nomes finais.** *Decisão:* `assistant`, `resource`, `skill`, `label`, `title`, `context` e
281
+ `runtime.readResource`. `skill` acompanha o identificador técnico que os consumidores já usam;
282
+ para pessoas, a Grand Brasil continua dizendo Habilidade.
283
+ 2. **Teto do contexto.** *Decisão:* 4.000 caracteres por padrão, 1.000 no mínimo e 16.000 no
284
+ máximo (seções 1 e 3). Cabe o resumo de um cadastro e fica longe do teto de histórico que a
285
+ Grand Brasil usa (40.000).
286
+ 3. **Projeção nas respostas de ferramenta.** *Decisão:* não aplicar, pelos motivos da seção 3.
287
+ 4. **Rótulo do botão da Presentation.** *Decisão:* `triggerLabel` e `assistant.label` ficam
288
+ independentes (seção 8). Herdar o rótulo da action do corpo na Presentation, caso a repetição
289
+ apareça na prática, pede uma decisão própria.
290
+ 5. **Versão.** *Decisão:* minor, porque o campo é opcional e a regra só cobra quem declara.
291
+
292
+ **Nota de implementação (2026-10-03).** A implementação preencheu os pontos que a decisão deixava em
293
+ aberto assim:
294
+
295
+ - **Projeção sob pedido.** `runtime.readResource` ganhou um quarto parâmetro opcional,
296
+ `{ context?: boolean }`. Sem ele, a leitura devolve só o descritor, que é o que a listagem de um
297
+ histórico usa, e resolve apenas o título (`resolveAssistantTitle`), sem serializar nem cortar a
298
+ saída; com `context: true`, devolve também a projeção. A execução da action e a autorização são as
299
+ mesmas nos dois casos. O aviso de título ausente vale sempre; o de caminho de `context` ausente,
300
+ só quando a projeção roda.
301
+ - **Falha na projeção.** Uma saída que o JSON não representa (`BigInt`, referência circular) não
302
+ derruba a leitura com exceção: `readResource` registra o erro no log e devolve
303
+ `runtime.assistant_projection_failed` (categoria `internal`). A execução da action já foi
304
+ auditada.
305
+ - **Tipo sem dona.** `runtime.assistant_resource_unknown` tem mensagem fixa; o tipo recebido vai em
306
+ `meta.resource`, para não ecoar entrada de fora no texto do erro.
307
+ - **Registro.** Além do dono duplicado e do teto, o `register` recusa a forma inválida da
308
+ declaração com `runtime.assistant_declaration_invalid`: `assistant` fora de `view`, `ai` não
309
+ habilitado, `personalData` não declarado, `resource` ou `skill` fora do formato, `label`, `title`
310
+ ou `context` ausentes ou caminho com segmento vazio. Quem não roda o `opus check` no gate recebe a
311
+ mesma régua ao subir o serviço. A entrada `{ id }` continua conferida só pelo `opus gen`, que
312
+ enxerga o JSON Schema; o core segue sem dependência de schema.
313
+ - **Teto nas projeções.** `aiTools()`, o `_meta` do MCP, o manifest e `runtime.assistants()` levam
314
+ a mesma forma, com `maxContextChars` já resolvido (4.000 quando não declarado), para o host que
315
+ aplica `projectAssistantContext` por outro canal usar o teto declarado. O `opus check` também reprova
316
+ `maxContextChars` literal fora de 1.000 a 16.000; um valor não literal fica para o registro.
317
+ - **`omitted`.** Cada entrada é `{ path, reason, count? }`, com `reason` igual a `items` (itens
318
+ removidos do fim da lista, `count` diz quantos), `shortened`, `removed` ou `missing`. O caminho é
319
+ o da saída, sem índice de lista: em `removed` e `missing`, é o declarado (ou o do título); em
320
+ `items` e `shortened`, pode ficar abaixo do declarado quando o caminho declarado termina num
321
+ objeto ou numa lista. Um título ausente, nulo ou que não é texto nem número vira `title: null` e
322
+ entra em `omitted` como `missing`, com o aviso no log. O título passa por `toJSON` como na
323
+ serialização, para sair igual com e sem a projeção (uma data vira o texto ISO nos dois casos). O `opus gen` recusa `title` que não
324
+ resolve para texto ou número, fora de lista, em algum ramo não nulo.
325
+ - **Custo do corte.** As etapas 1 e 2 escolhem a maior lista e o texto mais longo por fila de
326
+ prioridade, sem varrer a projeção a cada passo: o custo cresce com o tamanho da saída. Um teste
327
+ roda os cenários num processo filho com prazo, porque o laço é síncrono e o prazo do teste não o
328
+ interromperia.
329
+ - **Segmentos reservados.** `__proto__`, `constructor` e `prototype` não são aceitos como segmento
330
+ de `title` ou `context` no registro, no `opus gen` e, quando literais, no `opus check`; a projeção
331
+ grava as chaves como propriedades próprias.
332
+ - **Caminhos sobrepostos.** Um caminho coberto por outro declarado, como `leads.totalCount` junto de
333
+ `leads`, é ignorado na projeção, porque o mais curto já leva o valor inteiro.
334
+ - **Bloco ao modelo.** A função é `resourceDataBlock`, de `@softize/opus/ai`. O bloco é uma linha de
335
+ JSON entre `<resource-data>` e `</resource-data>` (a etiqueta pode ser trocada por outra em
336
+ minúsculas com hífen). Saem como escapes `\u`, que o JSON lê como o mesmo texto: `<` e `>`; os
337
+ sinais que a normalização NFKC transforma neles; os confusáveis cujo esqueleto contém `<` ou `>`,
338
+ derivados do confusables.txt do Unicode (UTS #39, versão de 2026-08-06); os colchetes angulares
339
+ de outros blocos; os caracteres de controle (C0, DEL, C1 e NEL); os de formatação
340
+ invisíveis; os seletores de variação; os preenchimentos do hangul; os separadores de linha e
341
+ parágrafo; e os espaços que não são o espaço comum. `truncated` e `omitted` entram quando houve
342
+ corte ou quando faltou algum caminho.
343
+ - **`opus gen`.** Um campo sem forma declarada na saída (`z.unknown()`, `z.any()`) aceita qualquer
344
+ caminho abaixo dele, porque não há o que conferir. Um schema que não pôde ser convertido em JSON
345
+ Schema recusa o manifest. Na entrada, além de `id`, campos opcionais sem valor padrão também são
346
+ aceitos: a decisão só recusa outro campo obrigatório.
347
+ - **`label`.** É texto simples, não `I18nRef`, como o `triggerLabel` da Presentation. O `opus copy`
348
+ o inventaria com o papel `title`, porque ele nomeia o assistente na superfície do chat.
349
+ - **Aviso de caminho ausente.** O log usa o código `runtime.assistant_path_missing`, com a action,
350
+ o recurso e os caminhos.
351
+ - **Caminho que termina num contêiner.** Um caminho de `context` que termina num objeto ou numa
352
+ lista projeta tudo o que está abaixo dele, inclusive campos que a saída ganhar depois. O consumidor
353
+ que mantém uma lista de campos proibidos precisa conferir os descendentes, e não só o último
354
+ segmento do caminho. Para isso, `@softize/opus/assistant-schema` exporta
355
+ `assistantContextLeaves(output, context)`: a partir do JSON Schema da saída, como o manifest o
356
+ traz, devolve por caminho os campos folha alcançados e os pontos sem forma declarada ou em que um
357
+ `$ref` volta a si mesmo, por onde qualquer campo pode passar. O subpath publica tipos
358
+ (`assistant-schema.d.mts`) para um teste em TypeScript estrito.
359
+
360
+ ## Consequências
361
+
362
+ - A action dona passa a ser a fonte única de leitura, acesso, procedimento e dado ao modelo para um
363
+ tipo de recurso. O consumidor troca tabelas no host, montagem na tela e `if` por tipo por uma
364
+ declaração e duas leituras do runtime.
365
+ - O manifest cresce com `ai` e `assistant`. Projetos que versionam o manifest regeneram com
366
+ `opus gen` na atualização.
367
+ - A Lente passa a mostrar, por action, o que a IA alcança e o que cada assistente entrega ao
368
+ modelo. Depende de uma versão nova da Lente.
369
+ - O campo é opcional e entra numa minor. A regra do `opus check` vale só para quem declara.
370
+ - A conferência de acesso executa a action inteira. Uma action dona pesada fica cara para listar
371
+ conversas; o consumidor mede e, se preciso, escolhe uma `view` mais leve como dona.
372
+ - A existência do procedimento continua fora do alcance do SDK. Uma chave errada só aparece no
373
+ teste do consumidor ou no turno.
374
+
375
+ ## Alternativas descartadas
376
+
377
+ ### Manter a política no host
378
+
379
+ É o estado atual. Cada consumidor repete tabela de procedimentos, montagem de contexto e rota por
380
+ tipo, e nada disso aparece no manifest nem na Lente.
381
+
382
+ ### Declarar dentro de `ai`
383
+
384
+ `ai: { assistant: { ... } }` deixaria tudo que é de IA num lugar. Mas a declaração também define a
385
+ action dona do recurso, usada para conferir acesso em telas e listagens que não são da IA, como o
386
+ histórico de conversas. É o mesmo motivo que levou `effect` e `personalData` para fora de `ai` na
387
+ ADR 0041.
388
+
389
+ ### Declarar na Presentation
390
+
391
+ A Presentation é interface, e o recurso precisa do assistente também onde não há Presentation: num
392
+ drawer composto pelo consumidor ou no chat geral, que não tem superfície.
393
+
394
+ ### Projeção por função
395
+
396
+ `context: (output) => ...` seria mais flexível, mas não é serializável. Sem uma lista de caminhos,
397
+ o manifest e a Lente não conseguem mostrar quais dados vão ao modelo, que é o objetivo da
398
+ declaração.
399
+
400
+ ### Validar o procedimento no `opus check`
401
+
402
+ O check é estático e o registro de procedimentos é dado de banco, diferente em cada ambiente.
403
+ Reprovar ali exigiria acesso ao banco no gate ou uma cópia do registro no repositório.
404
+
405
+ ### Aplicar a projeção também às respostas de ferramenta
406
+
407
+ Minimizaria o dado ao modelo, mas a mesma action devolveria coisas diferentes por consumidor, e os
408
+ procedimentos que precisam da saída completa, como editar um relatório pela revisão lida,
409
+ deixariam de funcionar.
410
+
411
+ ## Verificação
412
+
413
+ - Testes do SDK:
414
+ - `assistant` numa action que não é `view`, sem `ai` ou sem `personalData` falha no `opus check`;
415
+ - dois contratos com o mesmo `resource` falham no `opus check` e no `register`;
416
+ - `opus gen` recusa `title` ou caminho de `context` ausentes na saída, aceita caminho que existe
417
+ num ramo não nulo de `anyOf` e recusa entrada sem `id` ou com outro campo obrigatório;
418
+ - `readResource` executa a action dona pelo `execute`: a autorização da action vale, o registro
419
+ de auditoria é gerado e a marca de dado pessoal aparece;
420
+ - `readResource` com tipo sem action dona devolve `runtime.assistant_resource_unknown` sem
421
+ executar nada;
422
+ - a projeção devolve só os caminhos declarados, inclusive dentro de listas, e `null` quando um
423
+ campo intermediário é nulo;
424
+ - um caminho ausente em execução é omitido com motivo `missing` e aviso no log, sem falhar;
425
+ - acima do teto, a projeção segue as etapas da seção 3 na ordem, marca `truncated`, lista o que
426
+ saiu em `omitted` e termina mesmo com o teto mínimo;
427
+ - um título acima de 200 caracteres é encurtado e registrado em `omitted`, sem entrar na conta do
428
+ teto;
429
+ - o bloco entregue ao modelo leva `truncated` e `omitted`, e a função de bloco neutraliza o
430
+ delimitador dentro dos valores;
431
+ - o manifest projeta `ai` e `assistant`; `aiTools()` e o `ListTools` do MCP levam a declaração;
432
+ - `opus copy` inventaria `assistant.label`.
433
+ - Documentação e skills do pacote, no mesmo entregável:
434
+ - `docs/protocol.md` ganha o campo na definição de action (seção 2), o princípio da seção 6 desta
435
+ ADR e o verbete no glossário;
436
+ - a página de actions da documentação (`src/ui/docs/content/actions.md`) mostra o campo e a ordem
437
+ canônica com ele;
438
+ - as skills `create-opus-action` e `test-opus-action` (`registry/skills`) ensinam a declarar e a
439
+ testar o assistente.
440
+ - Na Lente: a página de uma action com assistente mostra publicação para a IA, efeito, dado pessoal,
441
+ procedimento e campos enviados ao modelo.
442
+ - Na Grand Brasil, a adoção (ADR 0111 dela) declara as quatro actions donas e troca a rota por tipo
443
+ pela leitura do runtime.
@@ -0,0 +1,82 @@
1
+ # ADR 0054 — Agrupamento da ActionList é um recorte declarado e consultado no servidor
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-03.
5
+ - **Relacionadas:** ADR 0013 (Presentation orientada a action) e ADR 0024 (preenchimento das
6
+ regiões de lista).
7
+
8
+ ## Contexto
9
+
10
+ Listagens operacionais precisam, em alguns casos, comparar conjuntos dentro do mesmo recorte. A
11
+ fila de eventos da Grand Brasil é o primeiro caso: a pessoa quer reunir os eventos por status sem
12
+ perder busca, filtros, ordenação, contagens ou paginação.
13
+
14
+ Agrupar apenas os itens carregados criaria uma representação falsa. Uma página de cinquenta itens
15
+ não permite concluir quantos registros existem em cada grupo, e trocar de página faria grupos
16
+ sumirem ou mudarem de tamanho. Acrescentar um envelope agregado ao protocolo resolveria isso com
17
+ uma consulta, mas obrigaria todo handler e adapter a aprender uma segunda forma de resposta antes de
18
+ existir evidência de que o custo compensa.
19
+
20
+ O campo também não pode ser inferido de toda coluna ou filtro. Texto livre e lookup podem ter
21
+ cardinalidade alta, data e número exigem faixas, e nem toda dimensão fechada faz sentido como
22
+ organização da lista. A decisão precisa continuar no contrato de produto.
23
+
24
+ ## Decisão
25
+
26
+ ### 1. A action declara as dimensões permitidas
27
+
28
+ Uma `ListAction` pode declarar:
29
+
30
+ ```ts
31
+ grouping: {
32
+ fields: ['status', 'stage'],
33
+ }
34
+ ```
35
+
36
+ Cada nome referencia um filtro `select` com opções `static` ou `dictionary`. O controle do filtro
37
+ pode aceitar uma ou várias opções; cada consulta de grupo envia um único valor na forma esperada
38
+ pelo filtro. A declaração não define agrupamento inicial: a lista continua plana até a pessoa
39
+ escolher “Agrupar por” em **Exibição**. O estado usa `groupBy` e a URL usa `group=<campo>`.
40
+
41
+ O Opus rejeita dimensões ausentes, filtros de outro tipo, lookups, conjuntos estáticos sem opções
42
+ fechadas e actions sem `paginate: 'offset'`. A interface oferece apenas dicionários resolvidos com
43
+ até doze opções por dimensão; acima disso, a declaração não é adequada para agrupamento operacional.
44
+
45
+ ### 2. Cada grupo é uma consulta da mesma ListAction
46
+
47
+ O agrupamento preserva o input base, a busca, o período, os demais filtros e a ordenação. Para cada
48
+ opção da dimensão, o Opus executa a mesma action com o valor do grupo aplicado ao filtro. Um filtro
49
+ já aplicado à dimensão restringe quais grupos aparecem; não é removido nem reinterpretado.
50
+
51
+ Cada resposta precisa devolver `total`. A ausência desse dado vira o estado de erro da lista, sem
52
+ lançar uma exceção durante o render. Esse total nomeia o grupo e governa sua paginação. O primeiro
53
+ recorte usa o limite da lista; **Carregar mais** busca a página seguinte daquele grupo e acrescenta
54
+ os itens, sem reiniciar os demais. A invalidação ou a atualização manual refaz todas as consultas.
55
+
56
+ Essa primeira implementação aceita o custo de até doze consultas em paralelo. Um envelope de
57
+ facetas ou uma action agregada só será introduzido quando medição real justificar outra fronteira.
58
+
59
+ ### 3. O agrupamento pertence à tabela
60
+
61
+ A tabela mantém um único cabeçalho e abre um `tbody` por grupo. O cabeçalho do grupo é recolhível,
62
+ expõe `aria-expanded`, apresenta o valor pelo dicionário quando houver e mostra a contagem correta,
63
+ inclusive zero. O agrupamento não se aplica a `children` nem a views alternativas; ao sair da tabela,
64
+ os dados voltam à consulta plana.
65
+
66
+ Busca, filtros e período são aplicados antes do agrupamento; ordenação vale dentro de cada grupo. A
67
+ seleção em lote cobre os itens carregados dos grupos abertos. Recolher um grupo remove da seleção os
68
+ itens dele, para nenhuma ação operar sobre linhas invisíveis.
69
+
70
+ Se uma consulta falhar, a lista apresenta o erro padrão e permite refazer o conjunto. Se todos os
71
+ totais forem zero, há um único estado vazio. Não existe paginador global enquanto o agrupamento está
72
+ ativo; o rodapé mostra apenas a soma dos totais.
73
+
74
+ ## Consequências
75
+
76
+ - O contrato distingue capacidade de agrupamento de uma seleção corrente.
77
+ - Contagens e páginas continuam autoritativas no servidor; não há agrupamento parcial silencioso.
78
+ - O número de requisições cresce com as opções da dimensão, de forma explícita e limitada.
79
+ - O primeiro consumidor deve testar que a soma dos totais agrupados coincide com o total da lista
80
+ plana para o mesmo recorte.
81
+ - Uma futura otimização de protocolo pode preservar `grouping` e `groupBy`, trocando apenas como os
82
+ dados chegam ao cliente.