@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,72 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 25.3.0 — 2026-10-05
11
+
12
+ `ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
13
+ Cada dimensão referencia um filtro `select` com até 12 opções estáticas ou de dicionário, e a
14
+ action precisa usar `paginate: 'offset'` e devolver `total`. A pessoa escolhe o agrupamento em
15
+ **Exibição**; cada grupo consulta o servidor separadamente, pode ser recolhido e carrega páginas
16
+ adicionais sem descartar as anteriores. O agrupamento não altera views alternativas nem o modo de
17
+ composição por `children`.
18
+
19
+ O manifest e os stubs passam a projetar `grouping`. `ActionListState` e os helpers de URL preservam
20
+ a dimensão em `group`; dimensões desconhecidas são ignoradas. A implementação das consultas por
21
+ grupo permanece interna à `ActionList`, sem introduzir um hook público adicional.
22
+
23
+ `useListAction(action, input, { enabled })` pode suspender a consulta declarativa sem desmontar o
24
+ hook. O padrão é `true`; a `ActionList` usa essa opção internamente ao alternar entre a consulta
25
+ plana e as consultas agrupadas.
26
+
27
+ ## 25.2.0 — 2026-10-03
28
+
29
+ Uma action `view` pode declarar o assistente do recurso que ela lê (ADR 0053). Com isso, a conversa
30
+ especializada num cliente, num relatório ou num pedido passa a ler o recurso, conferir o acesso,
31
+ saber o procedimento da IA e escolher o dado ao modelo a partir de uma declaração só, no contrato:
32
+
33
+ ```ts
34
+ ai: true,
35
+ assistant: {
36
+ resource: 'customer',
37
+ skill: 'review-customer',
38
+ label: 'Assistente do cliente',
39
+ title: 'name',
40
+ context: ['kind', 'stage', 'leads.totalCount'],
41
+ },
42
+ ```
43
+
44
+ A action que declara passa a ser a action dona daquele tipo de recurso. Ela precisa ser `view`,
45
+ estar publicada para a IA, declarar `personalData` e receber só `{ id }`.
46
+
47
+ - `runtime.readResource(resource, id, base, { context })` executa a action dona pelo mesmo
48
+ `execute` de qualquer chamada, com a autorização, a auditoria e a marca de dado pessoal dela, e
49
+ devolve o descritor (`kind`, `id`, `title`, `label`, `skill`) e, com `context: true`, a projeção
50
+ limitada do dado. Um tipo sem dona devolve `runtime.assistant_resource_unknown` sem executar nada.
51
+ - `runtime.assistants()` lista as declarações com a action dona, para o produto montar rotas sem
52
+ lista paralela e conferir num teste que cada `skill` existe no registro dele.
53
+ - `projectAssistantContext(assistant, output)` devolve só os caminhos declarados e, acima do teto
54
+ (4.000 caracteres por padrão, de 1.000 a 16.000 em `maxContextChars`), corta por etapas até
55
+ caber, marcando `truncated` e listando o que saiu em `omitted`. Caminho ausente na saída é
56
+ omitido com o motivo `missing` e gera aviso no log, sem falhar a leitura.
57
+ - `resourceDataBlock`, de `@softize/opus/ai`, monta o bloco delimitado que o host entrega ao modelo
58
+ numa mensagem de dado, com os escapes que impedem um valor de fechar o delimitador.
59
+ - `runtime.aiTools()` leva `metadata.assistant`, e o `ListTools` do MCP publica
60
+ `com.softize.opus/assistant` em `_meta`.
61
+ - O registro recusa a declaração inválida (`runtime.assistant_declaration_invalid`), o teto fora do
62
+ intervalo (`runtime.assistant_context_limit_invalid`) e duas donas do mesmo recurso
63
+ (`runtime.assistant_duplicate_resource`). O `opus check` ganha a regra `assistant-declaration`, o
64
+ `opus gen` confere `title`, `context` e a entrada contra os schemas, e o `opus copy` inventaria
65
+ `assistant.label`.
66
+ - `@softize/opus/assistant-schema`, com tipos, exporta `assistantContextLeaves(output, context)`,
67
+ que lista, a partir do JSON Schema da saída no manifest, os campos folha que cada caminho de
68
+ `context` leva ao modelo. Um caminho que termina num objeto ou numa lista leva tudo abaixo dele; use a função num
69
+ teste para garantir que nenhum campo pessoal chega ao modelo por esse caminho.
70
+
71
+ O manifest passa a projetar, em toda action, a publicação para a IA em `ai` (`enabled`,
72
+ `description`, `destructive`, `requiresConfirmation`) e, na action dona, `assistant`. Se o seu
73
+ projeto versiona o manifest, rode `opus gen` depois da atualização: sem isso, a conferência de
74
+ frescor do manifest acusa a diferença. Nada muda para quem não declara assistente.
75
+
10
76
  ## 25.1.0 — 2026-10-03
11
77
 
12
78
  `FloatingLayer`, de `@softize/opus/ui/react`, é uma camada que continua utilizável por cima de
package/bin/cli.mjs CHANGED
@@ -426,6 +426,11 @@ Regras das actions:
426
426
  binding (o join contrato↔bind é cross-file)
427
427
  ignored-by-kind — \`emits\` ou \`background\` em action list/view: o runtime
428
428
  e o manifest só os aplicam em simple e form
429
+ assistant-declaration — o assistente do recurso (ADR 0053): só em view, com \`ai\`
430
+ habilitado, \`personalData\` declarado, \`resource\` em camelCase,
431
+ \`skill\` em minúsculas com hífen, \`label\`, \`title\` e
432
+ \`context\` presentes e \`maxContextChars\`, quando literal,
433
+ de 1.000 a 16.000; o mesmo \`resource\` em duas actions reprova
429
434
 
430
435
  Regras da UI (@softize/opus/ui):
431
436
  ui-structure — anatomia pública das superfícies compostas (ADR 0005):
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tipos de `@softize/opus/assistant-schema` (ADR 0053): a conferência do assistente do recurso
3
+ * contra o JSON Schema da action, como o manifest o traz. A implementação é `assistant-schema.mjs`.
4
+ */
5
+
6
+ /** Um JSON Schema como o `opus gen` o projeta no manifest. */
7
+ export type JsonSchema = Record<string, unknown>
8
+
9
+ /** Os campos que um caminho de `context` leva ao modelo. */
10
+ export interface AssistantContextLeaves {
11
+ /** O caminho declarado em `context`. */
12
+ path: string
13
+ /** Os campos folha alcançados, com ponto e sem índice de lista; `*` marca a chave de um mapa. */
14
+ leaves: string[]
15
+ /**
16
+ * Pontos em que o schema não declara forma ou volta a si mesmo por `$ref`: qualquer campo abaixo
17
+ * deles pode ir ao modelo, e a conferência não alcança.
18
+ */
19
+ open: string[]
20
+ }
21
+
22
+ /**
23
+ * Os campos folha que cada caminho de `context` leva ao modelo, a partir do JSON Schema da saída.
24
+ * Serve a um teste do consumidor que reprova campo pessoal alcançado por um caminho que termina
25
+ * num objeto ou numa lista.
26
+ */
27
+ export function assistantContextLeaves(
28
+ output: JsonSchema,
29
+ context: readonly string[],
30
+ root?: JsonSchema,
31
+ ): AssistantContextLeaves[]
32
+
33
+ /** O caminho existe em algum ramo não nulo do schema; listas são atravessadas. */
34
+ export function schemaHasPath(schema: JsonSchema, segments: readonly string[], root?: JsonSchema): boolean
35
+
36
+ /** O título resolve para texto ou número, fora de lista, em algum ramo não nulo. */
37
+ export function schemaTitleIsText(schema: JsonSchema, segments: readonly string[], root?: JsonSchema): boolean
38
+
39
+ /**
40
+ * Os problemas do assistente diante dos schemas de entrada e de saída, como frases. Lista vazia
41
+ * quando a declaração confere.
42
+ */
43
+ export function assistantSchemaIssues(args: {
44
+ assistant: { title?: unknown; context?: unknown }
45
+ input: unknown
46
+ output: unknown
47
+ }): string[]
48
+
49
+ /** O `ai` de uma action normalizado como o manifest o projeta. */
50
+ export function serializeAiConfig(ai: unknown): {
51
+ enabled: boolean
52
+ description: string | null
53
+ destructive: boolean
54
+ requiresConfirmation: boolean
55
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * assistant-schema — confere o assistente do recurso (ADR 0053) contra o JSON Schema da action,
3
+ * no `opus gen`: `title` e cada caminho de `context` precisam existir na saída, e a entrada precisa
4
+ * ser um objeto em que `id`, do tipo texto, é o único campo obrigatório.
5
+ *
6
+ * Num campo anulável ou numa união (`anyOf`, `oneOf`, `allOf`), o caminho vale se existir em algum
7
+ * ramo que não seja `null`. Uma lista é atravessada: o caminho continua nos itens. Um schema sem
8
+ * forma declarada (`{}`, de `z.unknown()` ou `z.any()`) aceita qualquer caminho abaixo dele, porque
9
+ * não há o que conferir.
10
+ */
11
+
12
+ const COMBINATORS = ["anyOf", "oneOf", "allOf"];
13
+ const STRUCTURE_KEYS = ["type", "properties", "items", "additionalProperties", "enum", "const"];
14
+ const MAX_DEPTH = 64;
15
+ // Segmentos que mexem no protótipo de um objeto JavaScript; o registro do runtime também os recusa.
16
+ const RESERVED_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
17
+
18
+ function reservedSegments(path) {
19
+ return typeof path === "string" ? path.split(".").filter((segment) => RESERVED_SEGMENTS.has(segment)) : [];
20
+ }
21
+
22
+ function resolveRef(root, ref) {
23
+ if (!ref.startsWith("#/")) return undefined;
24
+ let node = root;
25
+ for (const raw of ref.slice(2).split("/")) {
26
+ const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
27
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) return undefined;
28
+ node = node[key];
29
+ }
30
+ return node;
31
+ }
32
+
33
+ function isNullOnly(schema) {
34
+ if (schema.type === "null") return true;
35
+ if (Array.isArray(schema.type) && schema.type.every((type) => type === "null")) return true;
36
+ // O alvo OpenAPI 3.0 do zod-to-json-schema escreve o ramo nulo como `{ enum: ['null'], nullable: true }`.
37
+ return (
38
+ Array.isArray(schema.enum) &&
39
+ schema.enum.length > 0 &&
40
+ schema.enum.every((value) => value === null || value === "null") &&
41
+ (schema.nullable === true || schema.enum.includes(null))
42
+ );
43
+ }
44
+
45
+ const NO_REFS = new Set();
46
+ /** Marca o ponto em que um `$ref` volta a um schema que já está no caminho atual. */
47
+ const CYCLE = Object.freeze({ cycle: true });
48
+
49
+ /**
50
+ * Os ramos concretos e não nulos de um schema, com `$ref` e combinadores expandidos. Cada ramo traz
51
+ * os `$ref` percorridos no caminho atual; um `$ref` que se repete ali vira `CYCLE`, em vez de
52
+ * expandir de novo.
53
+ */
54
+ function expand(schema, root, active = NO_REFS, depth = 0) {
55
+ if (schema === null || typeof schema !== "object" || depth > MAX_DEPTH) return [];
56
+ if (typeof schema.$ref === "string") {
57
+ if (active.has(schema.$ref)) return [{ schema: CYCLE, active }];
58
+ const target = resolveRef(root, schema.$ref);
59
+ return target === undefined ? [] : expand(target, root, new Set([...active, schema.$ref]), depth + 1);
60
+ }
61
+ const nested = COMBINATORS.flatMap((key) =>
62
+ Array.isArray(schema[key]) ? schema[key].flatMap((branch) => expand(branch, root, active, depth + 1)) : [],
63
+ );
64
+ const hasOwnStructure = STRUCTURE_KEYS.some((key) => Object.hasOwn(schema, key));
65
+ const combined = COMBINATORS.some((key) => Array.isArray(schema[key]));
66
+ if (combined && !hasOwnStructure) return nested;
67
+ if (isNullOnly(schema)) return nested;
68
+ return [{ schema, active }, ...nested];
69
+ }
70
+
71
+ /** Os ramos sem os ciclos, para quem só olha o tipo do próprio nó. */
72
+ function branches(schema, root) {
73
+ return expand(schema, root).flatMap(({ schema: branch }) => (branch === CYCLE ? [] : [branch]));
74
+ }
75
+
76
+ function isOpen(schema) {
77
+ return (
78
+ !Object.hasOwn(schema, "$ref") &&
79
+ !STRUCTURE_KEYS.some((key) => Object.hasOwn(schema, key)) &&
80
+ !COMBINATORS.some((key) => Object.hasOwn(schema, key))
81
+ );
82
+ }
83
+
84
+ function isArraySchema(schema) {
85
+ return schema.type === "array" || (Array.isArray(schema.type) && schema.type.includes("array")) || Object.hasOwn(schema, "items");
86
+ }
87
+
88
+ /**
89
+ * Os schemas alcançados por `segments` em todos os ramos não nulos. Um schema sem forma alcança
90
+ * qualquer caminho abaixo dele e entra como está. Com `throughLists`, uma lista é atravessada. Um
91
+ * ciclo de `$ref` sem avançar no caminho (por lista ou união) não leva a lugar nenhum; ao descer
92
+ * por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore.
93
+ */
94
+ function collectTargets(schema, segments, root, throughLists, out, active = NO_REFS, depth = 0) {
95
+ if (depth > MAX_DEPTH) return;
96
+ if (segments.length === 0) {
97
+ out.push(schema);
98
+ return;
99
+ }
100
+ for (const { schema: branch, active: refs } of expand(schema, root, active)) {
101
+ if (branch === CYCLE) continue;
102
+ if (isOpen(branch)) {
103
+ out.push(branch);
104
+ continue;
105
+ }
106
+ if (isArraySchema(branch)) {
107
+ if (!throughLists) continue;
108
+ const items = Array.isArray(branch.items) ? branch.items : [branch.items];
109
+ for (const item of items) {
110
+ if (item !== undefined) collectTargets(item, segments, root, throughLists, out, refs, depth + 1);
111
+ }
112
+ continue;
113
+ }
114
+ const [head, ...rest] = segments;
115
+ const properties = branch.properties;
116
+ if (properties !== null && typeof properties === "object" && Object.hasOwn(properties, head)) {
117
+ collectTargets(properties[head], rest, root, throughLists, out, NO_REFS, depth + 1);
118
+ }
119
+ const additional = branch.additionalProperties;
120
+ if (additional !== null && typeof additional === "object") {
121
+ collectTargets(additional, rest, root, throughLists, out, NO_REFS, depth + 1);
122
+ }
123
+ }
124
+ }
125
+
126
+ /** O caminho `segments` existe em algum ramo não nulo de `schema`. */
127
+ export function schemaHasPath(schema, segments, root = schema) {
128
+ const targets = [];
129
+ collectTargets(schema, segments, root, true, targets);
130
+ return targets.length > 0;
131
+ }
132
+
133
+ const TITLE_TYPES = new Set(["string", "number", "integer"]);
134
+
135
+ /**
136
+ * O título resolve para texto ou número em algum ramo não nulo, sem atravessar lista: é o que o
137
+ * runtime consegue mostrar como nome do recurso.
138
+ */
139
+ export function schemaTitleIsText(schema, segments, root = schema) {
140
+ const targets = [];
141
+ collectTargets(schema, segments, root, false, targets);
142
+ return targets.some((target) =>
143
+ isOpen(target) ||
144
+ branches(target, root).some((branch) => {
145
+ if (isOpen(branch)) return true;
146
+ const types = Array.isArray(branch.type) ? branch.type : [branch.type];
147
+ return types.some((type) => TITLE_TYPES.has(type));
148
+ }),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * As folhas abaixo de `schema`. Um `$ref` que volta a um schema já no caminho atual (uma estrutura
154
+ * recursiva) entra em `open` naquele ponto: abaixo dele, qualquer profundidade pode chegar ao modelo.
155
+ */
156
+ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, depth = 0) {
157
+ if (depth > MAX_DEPTH) {
158
+ open.add(prefix);
159
+ return;
160
+ }
161
+ for (const { schema: branch, active: refs } of expand(schema, root, active)) {
162
+ if (branch === CYCLE || isOpen(branch)) {
163
+ open.add(prefix);
164
+ continue;
165
+ }
166
+ if (isArraySchema(branch)) {
167
+ const items = Array.isArray(branch.items) ? branch.items : [branch.items];
168
+ for (const item of items) if (item !== undefined) collectLeaves(item, prefix, root, leaves, open, refs, depth + 1);
169
+ continue;
170
+ }
171
+ const properties = branch.properties;
172
+ const hasProperties = properties !== null && typeof properties === "object" && Object.keys(properties).length > 0;
173
+ if (hasProperties) {
174
+ for (const [key, child] of Object.entries(properties)) {
175
+ collectLeaves(child, `${prefix}.${key}`, root, leaves, open, refs, depth + 1);
176
+ }
177
+ }
178
+ const additional = branch.additionalProperties;
179
+ if (additional !== null && typeof additional === "object") {
180
+ collectLeaves(additional, `${prefix}.*`, root, leaves, open, refs, depth + 1);
181
+ }
182
+ if (!hasProperties && !(additional !== null && typeof additional === "object")) leaves.add(prefix);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Os campos folha que cada caminho de `context` leva ao modelo, segundo o JSON Schema da saída
188
+ * (ADR 0053). Um caminho que termina num objeto ou numa lista projeta tudo abaixo dele; esta função
189
+ * lista o que é, para o consumidor conferir num teste, por exemplo, que nenhum campo pessoal chega
190
+ * ao modelo por um caminho que termina num contêiner.
191
+ *
192
+ * - `leaves`: caminhos das folhas, com ponto e sem índice de lista; `*` marca a chave de um mapa;
193
+ * - `open`: pontos em que o schema não declara forma (`z.unknown()`, `z.any()`) ou volta a si mesmo
194
+ * por `$ref`: qualquer campo abaixo deles pode ir ao modelo, e a conferência não alcança.
195
+ */
196
+ export function assistantContextLeaves(output, context, root = output) {
197
+ return context.map((path) => {
198
+ const targets = [];
199
+ collectTargets(output, path.split("."), root, true, targets);
200
+ const leaves = new Set();
201
+ const open = new Set();
202
+ for (const target of targets) collectLeaves(target, path, root, leaves, open);
203
+ return { path, leaves: [...leaves].sort(), open: [...open].sort() };
204
+ });
205
+ }
206
+
207
+ function isText(schema, root) {
208
+ return branches(schema, root).some(
209
+ (branch) => branch.type === "string" || (Array.isArray(branch.type) && branch.type.includes("string")),
210
+ );
211
+ }
212
+
213
+ function unserializable(schema) {
214
+ return schema === null || schema === undefined || schema.__unserializable === true;
215
+ }
216
+
217
+ /**
218
+ * Os problemas do assistente de uma action diante dos JSON Schemas de entrada e de saída, como
219
+ * frases. Lista vazia quando a declaração cabe nos schemas.
220
+ */
221
+ export function assistantSchemaIssues({ assistant, input, output }) {
222
+ const issues = [];
223
+ if (unserializable(input)) {
224
+ issues.push("o schema de entrada não pôde ser convertido em JSON Schema para conferir o `id`");
225
+ } else {
226
+ const objects = branches(input, input).filter(
227
+ (branch) => branch.type === "object" || Object.hasOwn(branch, "properties"),
228
+ );
229
+ if (objects.length !== 1) {
230
+ issues.push("a entrada precisa ser um objeto com `id`, do tipo texto, como único campo obrigatório");
231
+ } else {
232
+ const [object] = objects;
233
+ const required = Array.isArray(object.required) ? object.required : [];
234
+ const id = object.properties?.id;
235
+ if (id === undefined || !required.includes("id")) {
236
+ issues.push("a entrada precisa ter `id` obrigatório");
237
+ } else if (!isText(id, input)) {
238
+ issues.push("o `id` da entrada precisa ser do tipo texto");
239
+ }
240
+ const others = required.filter((key) => key !== "id");
241
+ if (others.length > 0) {
242
+ issues.push(
243
+ `a entrada só pode exigir \`id\`, mas também exige ${others.map((key) => `\`${key}\``).join(", ")}; dê um valor padrão a esses campos ou torne-os opcionais`,
244
+ );
245
+ }
246
+ }
247
+ }
248
+ const reserved = [assistant.title, ...(Array.isArray(assistant.context) ? assistant.context : [])].filter(
249
+ (path) => reservedSegments(path).length > 0,
250
+ );
251
+ if (reserved.length > 0) {
252
+ issues.push(
253
+ `${reserved.map((path) => `\`${path}\``).join(", ")} usa __proto__, constructor ou prototype, que não são campos da saída`,
254
+ );
255
+ return issues;
256
+ }
257
+ if (unserializable(output)) {
258
+ issues.push("o schema de saída não pôde ser convertido em JSON Schema para conferir `title` e `context`");
259
+ return issues;
260
+ }
261
+ const missing = (path) => typeof path !== "string" || !schemaHasPath(output, path.split("."));
262
+ if (missing(assistant.title)) {
263
+ issues.push(`\`${assistant.title}\`, de \`title\`, não existe na saída`);
264
+ } else if (!schemaTitleIsText(output, assistant.title.split("."))) {
265
+ issues.push(
266
+ `\`${assistant.title}\`, de \`title\`, precisa ser texto ou número, fora de lista, para nomear o recurso`,
267
+ );
268
+ }
269
+ const absent = (Array.isArray(assistant.context) ? assistant.context : []).filter(missing);
270
+ if (absent.length > 0) {
271
+ const list = absent.map((path) => `\`${path}\``).join(", ");
272
+ issues.push(`${list}, de \`context\`, ${absent.length === 1 ? "não existe" : "não existem"} na saída`);
273
+ }
274
+ return issues;
275
+ }
276
+
277
+ /** O `ai` da action normalizado para o manifest. */
278
+ export function serializeAiConfig(ai) {
279
+ const config = ai === true ? { enabled: true } : ai !== null && typeof ai === "object" ? ai : null;
280
+ return {
281
+ enabled: config !== null && config.enabled !== false,
282
+ description: typeof config?.description === "string" && config.description.length > 0 ? config.description : null,
283
+ destructive: config?.destructive === true,
284
+ requiresConfirmation: config?.requiresConfirmation === true,
285
+ };
286
+ }
package/bin/lib/check.mjs CHANGED
@@ -24,6 +24,12 @@
24
24
  * identificador exportado; um `entities` ou `purges` que o check não
25
25
  * consegue ler (spread, variável) suspende a regra de ausência
26
26
  * correspondente, sem falso positivo.
27
+ * assistant-declaration — o assistente do recurso (ADR 0053): só em action `view`, com `ai`
28
+ * habilitado, `personalData` declarado, `resource` em camelCase, `skill`
29
+ * em minúsculas com hífen, `label`, `title` e `context` presentes e
30
+ * `maxContextChars` literal de 1.000 a 16.000. O
31
+ * mesmo `resource` literal em duas actions, entre arquivos, reprova. Um
32
+ * `assistant` ou `ai` que não é literal suspende o que depende dele.
27
33
  *
28
34
  * A checagem cross-file de registro no runtime (graph) fica pra v2 (introspecção/MCP).
29
35
  * Saída: findings + exit ≠ 0 se houver erro. Zero actions num diretório NÃO-marcado
@@ -73,6 +79,7 @@ const FIELD_GROUP = {
73
79
  rateLimit: 5,
74
80
  idempotency: 5,
75
81
  ai: 6,
82
+ assistant: 6,
76
83
  automatable: 6,
77
84
  internal: 6,
78
85
  };
@@ -82,6 +89,17 @@ const FIELD_GROUP = {
82
89
  const SEG = "[a-z][a-z0-9]*(-[a-z0-9]+)*";
83
90
  const ACTION_NAME_RE = new RegExp(`^${SEG}(\\.${SEG})+$`);
84
91
  const KINDS = new Set(["simple", "form", "list", "view"]);
92
+ // O assistente do recurso (ADR 0053): o mesmo formato que o registro do runtime confere.
93
+ const ASSISTANT_RESOURCE_RE = /^[a-z][a-zA-Z0-9]*$/;
94
+ const ASSISTANT_SKILL_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
95
+ const ASSISTANT_CONTEXT_MIN = 1000;
96
+ const ASSISTANT_RESERVED_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
97
+ const ASSISTANT_CONTEXT_MAX = 16000;
98
+ const ASSISTANT_REQUIRED = {
99
+ label: "`label`, o nome do assistente para pessoas",
100
+ title: "`title`, o caminho do campo que nomeia o recurso",
101
+ context: "`context`, os caminhos dos campos que vão ao modelo",
102
+ };
85
103
  // O runtime e o manifest só aplicam estes campos em `simple` e `form`; o tipo do binding os aceita
86
104
  // em qualquer kind, então a declaração num list/view compila e não tem efeito.
87
105
  const EXECUTION_ONLY_FIELDS = ["emits", "background"];
@@ -977,6 +995,73 @@ export function parseActions(fileName, sourceText) {
977
995
  .filter(Boolean);
978
996
  }
979
997
 
998
+ function prop(obj, key) {
999
+ return obj.properties.find(
1000
+ (pr) =>
1001
+ ts.isPropertyAssignment(pr) && pr.name && pr.name.getText(sf) === key,
1002
+ );
1003
+ }
1004
+
1005
+ /** `ai` literal → "enabled" | "disabled"; ausente → null; não literal → "unknown". */
1006
+ function aiState(obj) {
1007
+ const p = prop(obj, "ai");
1008
+ if (!p) return obj.properties.some((pr) => pr.name?.getText(sf) === "ai") ? "unknown" : null;
1009
+ const value = p.initializer;
1010
+ if (value.kind === ts.SyntaxKind.TrueKeyword) return "enabled";
1011
+ if (value.kind === ts.SyntaxKind.FalseKeyword) return "disabled";
1012
+ if (ts.isObjectLiteralExpression(value)) {
1013
+ const enabled = prop(value, "enabled");
1014
+ if (!enabled) return "enabled";
1015
+ if (enabled.initializer.kind === ts.SyntaxKind.FalseKeyword) return "disabled";
1016
+ if (enabled.initializer.kind === ts.SyntaxKind.TrueKeyword) return "enabled";
1017
+ }
1018
+ return "unknown";
1019
+ }
1020
+
1021
+ /** O `assistant` literal → { readable, resource, skill, keys }; ausente → null. */
1022
+ function assistantDeclaration(obj) {
1023
+ const p = prop(obj, "assistant");
1024
+ if (!p) return null;
1025
+ if (!ts.isObjectLiteralExpression(p.initializer))
1026
+ return { readable: false, line: lineOf(p) };
1027
+ const value = p.initializer;
1028
+ const literal = (key) => {
1029
+ const found = prop(value, key);
1030
+ if (!found) return { present: objKeys(value).includes(key), text: null };
1031
+ return {
1032
+ present: true,
1033
+ text: ts.isStringLiteralLike(found.initializer) ? found.initializer.text : null,
1034
+ };
1035
+ };
1036
+ const limit = prop(value, "maxContextChars");
1037
+ let maxContextChars = null;
1038
+ if (limit) {
1039
+ const node = limit.initializer;
1040
+ if (ts.isNumericLiteral(node)) maxContextChars = Number(node.text.replace(/_/g, ""));
1041
+ else if (
1042
+ ts.isPrefixUnaryExpression(node) &&
1043
+ node.operator === ts.SyntaxKind.MinusToken &&
1044
+ ts.isNumericLiteral(node.operand)
1045
+ )
1046
+ maxContextChars = -Number(node.operand.text.replace(/_/g, ""));
1047
+ }
1048
+ const contextNode = prop(value, "context");
1049
+ const contextPaths =
1050
+ contextNode && ts.isArrayLiteralExpression(contextNode.initializer)
1051
+ ? contextNode.initializer.elements.filter(ts.isStringLiteralLike).map((element) => element.text)
1052
+ : [];
1053
+ return {
1054
+ readable: true,
1055
+ line: lineOf(p),
1056
+ resource: literal("resource"),
1057
+ skill: literal("skill"),
1058
+ title: literal("title"),
1059
+ contextPaths,
1060
+ maxContextChars,
1061
+ keys: objKeys(value),
1062
+ };
1063
+ }
1064
+
980
1065
  function visit(node) {
981
1066
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
982
1067
  const callee = node.expression.text;
@@ -992,6 +1077,8 @@ export function parseActions(fileName, sourceText) {
992
1077
  name: strProp(obj, "name"),
993
1078
  kind: strProp(obj, "kind"),
994
1079
  effect: strProp(obj, "effect"),
1080
+ ai: aiState(obj),
1081
+ assistant: assistantDeclaration(obj),
995
1082
  keys: objKeys(obj),
996
1083
  ident: constIdent(node),
997
1084
  exported: isExported(node),
@@ -1023,6 +1110,61 @@ export function parseActions(fileName, sourceText) {
1023
1110
  return actions;
1024
1111
  }
1025
1112
 
1113
+ /**
1114
+ * Regra `assistant-declaration` (ADR 0053) sobre um item: a forma do assistente do recurso. O
1115
+ * `resource` repetido entre actions é conferido no `checkProject`.
1116
+ */
1117
+ function assistantMessages(a) {
1118
+ const assistant = a.assistant;
1119
+ if (assistant === null || assistant === undefined) return [];
1120
+ const out = [];
1121
+ if (a.kind !== null && a.kind !== "view")
1122
+ out.push(
1123
+ `Só uma action view declara assistente, e esta é ${a.kind}. A leitura de um recurso identificado é o papel da view.`,
1124
+ );
1125
+ if (a.ai === null || a.ai === "disabled")
1126
+ out.push(
1127
+ "A action dona precisa estar publicada para a IA: declare `ai: true` ou `ai` sem `enabled: false`.",
1128
+ );
1129
+ if (!a.keys.includes("personalData"))
1130
+ out.push(
1131
+ "A action dona precisa declarar `personalData`, `true` ou `false`: a declaração decide como o host trata o dado que entrega ao modelo.",
1132
+ );
1133
+ if (!assistant.readable) return out;
1134
+ const { resource, skill } = assistant;
1135
+ if (!resource.present)
1136
+ out.push("O assistente precisa declarar `resource`, o tipo de recurso de que a action é dona.");
1137
+ else if (resource.text !== null && !ASSISTANT_RESOURCE_RE.test(resource.text))
1138
+ out.push(`\`resource: "${resource.text}"\` precisa estar em camelCase, como \`customer\` ou \`aftersaleOpportunity\`.`);
1139
+ if (!skill.present)
1140
+ out.push("O assistente precisa declarar `skill`, a chave do procedimento que a IA segue.");
1141
+ else if (skill.text !== null && !ASSISTANT_SKILL_RE.test(skill.text))
1142
+ out.push(`\`skill: "${skill.text}"\` precisa estar em minúsculas com hífen, como \`review-customer\`.`);
1143
+ const reserved = [assistant.title.text, ...assistant.contextPaths].filter(
1144
+ (path) =>
1145
+ typeof path === "string" &&
1146
+ path.split(".").some((segment) => ASSISTANT_RESERVED_SEGMENTS.has(segment)),
1147
+ );
1148
+ if (reserved.length > 0)
1149
+ out.push(
1150
+ `${reserved.map((path) => `\`${path}\``).join(", ")} usa __proto__, constructor ou prototype, que não são campos da saída.`,
1151
+ );
1152
+ const limit = assistant.maxContextChars;
1153
+ if (
1154
+ limit !== null &&
1155
+ (!Number.isInteger(limit) || limit < ASSISTANT_CONTEXT_MIN || limit > ASSISTANT_CONTEXT_MAX)
1156
+ )
1157
+ out.push(
1158
+ `\`maxContextChars: ${limit}\` precisa ser um inteiro de 1.000 a 16.000; sem ele, o teto é de 4.000 caracteres.`,
1159
+ );
1160
+ const missing = Object.keys(ASSISTANT_REQUIRED).filter((key) => !assistant.keys.includes(key));
1161
+ if (missing.length > 0)
1162
+ out.push(
1163
+ `O assistente precisa declarar ${missing.map((key) => ASSISTANT_REQUIRED[key]).join("; ")}.`,
1164
+ );
1165
+ return out;
1166
+ }
1167
+
1026
1168
  /** Aplica as regras locais (por arquivo) a um item → findings. */
1027
1169
  export function lintAction(a) {
1028
1170
  const out = [];
@@ -1088,6 +1230,15 @@ export function lintAction(a) {
1088
1230
  const ignored = ignoredByKindMessage(a.kind, a.keys);
1089
1231
  if (ignored !== null) at("ignored-by-kind", ignored);
1090
1232
 
1233
+ for (const message of assistantMessages(a))
1234
+ out.push({
1235
+ rule: "assistant-declaration",
1236
+ level: "error",
1237
+ action: label,
1238
+ line: a.assistant.line,
1239
+ message,
1240
+ });
1241
+
1091
1242
  if (
1092
1243
  a.form === "defineAction" &&
1093
1244
  a.keys.includes("requires") &&
@@ -1126,6 +1277,7 @@ export function checkProject(sources) {
1126
1277
  const entityNames = new Set();
1127
1278
  const actionNames = new Set();
1128
1279
  const retention = [];
1280
+ const assistantResources = new Map(); // resource literal → [{ item, file }]
1129
1281
  // Natureza da action (ADR 0041): declarações ausentes são contadas, não reprovadas, na minor.
1130
1282
  let missingEffect = 0;
1131
1283
  let missingPersonalData = 0;
@@ -1159,6 +1311,12 @@ export function checkProject(sources) {
1159
1311
  if (item.name !== null) actionNames.add(item.name);
1160
1312
  }
1161
1313
  for (const f of lintAction(item)) findings.push({ ...f, file });
1314
+ const resource = item.assistant?.resource?.text ?? null;
1315
+ if (resource !== null) {
1316
+ const owners = assistantResources.get(resource) ?? [];
1317
+ owners.push({ item, file });
1318
+ assistantResources.set(resource, owners);
1319
+ }
1162
1320
  if (item.form !== "bindAction") {
1163
1321
  const writesByGet = item.kind === "list" || item.kind === "view";
1164
1322
  if (!writesByGet && !item.keys.includes("effect")) missingEffect += 1;
@@ -1265,6 +1423,21 @@ export function checkProject(sources) {
1265
1423
  }
1266
1424
  }
1267
1425
 
1426
+ for (const [resource, owners] of assistantResources) {
1427
+ if (owners.length < 2) continue;
1428
+ const names = owners.map(({ item }) => item.name ?? item.ident ?? "(sem name)");
1429
+ for (const { item, file } of owners) {
1430
+ findings.push({
1431
+ rule: "assistant-declaration",
1432
+ level: "error",
1433
+ action: item.name ?? item.ident ?? "(sem name)",
1434
+ line: item.assistant.line,
1435
+ file,
1436
+ message: `O recurso "${resource}" tem mais de uma action dona (${names.join(", ")}). Cada tipo de recurso tem uma única action dona.`,
1437
+ });
1438
+ }
1439
+ }
1440
+
1268
1441
  findings.push(...checkRetention(retention));
1269
1442
 
1270
1443
  return {
package/bin/lib/copy.mjs CHANGED
@@ -1540,6 +1540,8 @@ export function extractCopyFromSource(file, sourceText) {
1540
1540
  addProperty(example, 'description', 'description', prefix)
1541
1541
  })
1542
1542
  nestedObject(object, 'errors', (error, prefix) => addProperty(error, 'description', 'error', prefix))
1543
+ // O nome do assistente do recurso (ADR 0053), que a interface mostra como título do chat.
1544
+ nestedObject(object, 'assistant', (assistant, prefix) => addProperty(assistant, 'label', 'title', prefix))
1543
1545
  }
1544
1546
 
1545
1547
  function extractPresentation(object) {