@softize/opus 25.0.0 → 25.2.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 (46) hide show
  1. package/CHANGELOG.md +93 -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 +3 -0
  8. package/bin/lib/gen-runner.mjs +29 -1
  9. package/bin/lib/mcp.mjs +1 -1
  10. package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
  11. package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
  12. package/docs/protocol.md +54 -0
  13. package/docs/releasing.md +8 -2
  14. package/docs/stacking-scale.md +53 -0
  15. package/package.json +10 -1
  16. package/registry/skills/create-opus-action/SKILL.md +8 -3
  17. package/registry/skills/create-opus-action/references/contract-and-binding.md +24 -1
  18. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  19. package/registry/skills/test-opus-action/SKILL.md +4 -1
  20. package/registry/skills/test-opus-action/references/evaluations.md +2 -0
  21. package/registry/skills/test-opus-action/references/harness.md +14 -0
  22. package/src/ai/index.ts +3 -0
  23. package/src/ai/resource-context.ts +113 -0
  24. package/src/core/assistant.ts +545 -0
  25. package/src/core/index.ts +23 -0
  26. package/src/core/runtime.ts +136 -0
  27. package/src/core/types.ts +34 -0
  28. package/src/mcp/index.ts +3 -0
  29. package/src/ui/components/primitives/dialog.tsx +285 -26
  30. package/src/ui/components/primitives/drawer.tsx +104 -13
  31. package/src/ui/components/primitives/floating-layer.tsx +79 -0
  32. package/src/ui/components/primitives/menu.tsx +17 -2
  33. package/src/ui/components/primitives/popover.tsx +6 -1
  34. package/src/ui/components/primitives/tooltip.tsx +6 -1
  35. package/src/ui/docs/content/actions.md +97 -1
  36. package/src/ui/docs/content/ai.md +4 -2
  37. package/src/ui/docs/content/cli.md +2 -1
  38. package/src/ui/docs/content/dialog.md +3 -0
  39. package/src/ui/docs/content/drawer.md +3 -0
  40. package/src/ui/docs/content/floating-layer.md +178 -0
  41. package/src/ui/docs/content/mcp.md +3 -1
  42. package/src/ui/docs/registry.tsx +6 -0
  43. package/src/ui/lib/dismiss-on-iframe.ts +4 -1
  44. package/src/ui/lib/modal-layers.ts +547 -0
  45. package/src/ui/meta.ts +6 -0
  46. package/src/ui/react.tsx +4 -0
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) {
@@ -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,
@@ -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),
@@ -434,6 +440,20 @@ function serializeAction(action, ctx) {
434
440
  base.expand = action.expand ?? null;
435
441
  }
436
442
 
443
+ if (action.kind === "view" && action.assistant !== undefined && action.assistant !== null) {
444
+ const issues = assistantSchemaIssues({
445
+ assistant: action.assistant,
446
+ input: base.input,
447
+ output: base.output,
448
+ });
449
+ if (issues.length > 0) {
450
+ throw declarationError(
451
+ `O assistente da action "${action.name}" não confere com o contrato (ADR 0053): ${issues.join("; ")}.`,
452
+ );
453
+ }
454
+ base.assistant = ctx.publicAssistant(action.assistant);
455
+ }
456
+
437
457
  return base;
438
458
  }
439
459
 
@@ -605,6 +625,14 @@ function emitError(message) {
605
625
  process.exit(1);
606
626
  }
607
627
 
628
+ /** Erro de declaração do consumidor: a mensagem basta, sem a pilha do runner. */
629
+ function declarationError(message) {
630
+ const err = new Error(message);
631
+ err.name = "OpusDeclarationError";
632
+ return err;
633
+ }
634
+
608
635
  main().catch((err) => {
636
+ if (err?.name === "OpusDeclarationError") return emitError(err.message);
609
637
  emitError(`runner crash: ${err.stack ?? err.message ?? String(err)}`);
610
638
  });
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))),