synthesisui 0.16.243 → 0.16.247

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * DE ONDE VEIO A RESPOSTA - e é um DADO, não uma frase (dono, 18/08).
3
+ *
4
+ * O pedido veio com a razão embutida: *"eu trataria a linha Source como algo
5
+ * estrutural mesmo, não só visual. Isso pode virar uma métrica de maturidade
6
+ * depois - porcentagem de respostas por receita, por composição, por
7
+ * improviso."*
8
+ *
9
+ * Então a procedência nasce como estrutura e o texto é DERIVADO dela. O
10
+ * contrário - escrever a frase à mão em cada resposta - produz duas verdades no
11
+ * dia em que uma delas for editada, e a que vai para a métrica é sempre a que
12
+ * ninguém leu.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────────
15
+ * O QUE CADA KIND SIGNIFICA, e a fronteira é a GARANTIA
16
+ *
17
+ * contract veio do que este sistema declara - receita, token, regra,
18
+ * vocabulário. A plataforma responde por isso.
19
+ * composed montado a partir de camadas porque não havia receita para o
20
+ * pedido. Vale, e vale menos que um contrato. (Nasce com o
21
+ * compose_context; hoje nada devolve isto, e é de propósito -
22
+ * um kind que ninguém emite é um número que mente por otimismo.)
23
+ * pointer um ponteiro para conhecimento de terceiro. Não é nosso, não é
24
+ * garantido, e a resposta diz o pacote. Mesma gramática que a
25
+ * plataforma já usa: "3rd-party · <pacote>".
26
+ * none este sistema não tinha nada para o pedido. É o kind mais
27
+ * valioso dos quatro: é ele que mede o improviso, e é o evento
28
+ * que a plataforma não contava.
29
+ *
30
+ * A TAXA DE IMPROVISO é `none` sobre o total, e ela é a pergunta que decide se
31
+ * a composição vale ser construída - respondida com dado em vez de sensação.
32
+ */
33
+ /**
34
+ * A LINHA QUE O AGENTE E A PESSOA LEEM - derivada, nunca escrita à mão.
35
+ *
36
+ * Ela serve dois leitores de uma vez: o dev sabe o quanto confiar no que
37
+ * acabou de receber, e o agente sabe se está pisando em contrato ou em
38
+ * composição. Um agente que não sabe a diferença trata as duas com a mesma
39
+ * confiança, e é assim que uma suposição nossa entra no repositório dele com
40
+ * cara de regra.
41
+ */
42
+ export function sourceLine(source) {
43
+ const label = source.kind === "pointer"
44
+ ? "3rd-party pointer"
45
+ : source.kind === "none"
46
+ ? "none"
47
+ : source.kind;
48
+ return source.detail
49
+ ? `Source: ${label} · ${source.detail}`
50
+ : `Source: ${label}`;
51
+ }
52
+ /**
53
+ * O texto com a procedência colada no fim - uma linha em branco antes, para
54
+ * ela não se misturar com a última frase da resposta.
55
+ */
56
+ export function withSource(answer) {
57
+ return `${answer.body}\n\n${sourceLine(answer.source)}`;
58
+ }
59
+ /**
60
+ * O VALOR GRAVADO, e o prefixo existe para o eixo ser legível numa tabela que
61
+ * já carrega outra coisa.
62
+ *
63
+ * `agent_reads.action` é `text` sem CHECK no banco (medido em produção), então
64
+ * um valor novo não custa migração - e uma coluna nova custaria a tabela
65
+ * inteira até a migração rodar. `src-` marca de qual eixo a linha fala:
66
+ * `describe`/`add` são gesto, `tool` é chamada, `src-*` é procedência.
67
+ *
68
+ * E UMA LACUNA LEVA O MOTIVO NO PRÓPRIO VALOR: `src-none-recipe`. Assim a
69
+ * pergunta agregada continua sendo um `like 'src-none%'` e a pergunta por
70
+ * motivo é a mesma consulta com o valor inteiro - sem coluna nova, e sem
71
+ * perder o total.
72
+ *
73
+ * Recebe a procedência INTEIRA e não o `kind`: com o kind sozinho, o motivo
74
+ * seria um segundo argumento que alguém esquece, e a linha gravada voltaria a
75
+ * ser um `none` cego.
76
+ */
77
+ export function provenanceAction(source) {
78
+ return source.kind === "none"
79
+ ? `src-none-${source.reason}`
80
+ : `src-${source.kind}`;
81
+ }
82
+ /**
83
+ * O NOME DA FERRAMENTA COMO A TABELA O GUARDA.
84
+ *
85
+ * A coluna `component` aceita `^[a-z][a-z0-9-]*$` na rota - a mesma forma de um
86
+ * slug de receita. As ferramentas são snake_case, então elas viajam em
87
+ * kebab-case; sem isto o ping seria recusado com 400 e a instrumentação
88
+ * silenciaria justamente na ferramenta nova.
89
+ */
90
+ export function toolSlug(tool) {
91
+ return tool.replace(/_/g, "-").toLowerCase();
92
+ }
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import { join, relative, resolve } from "node:path";
4
+ import { provenanceAction, toolSlug, withSource, } from "../agent-provenance.js";
4
5
  import { pinnedHookVersion } from "../agent-wiring.js";
6
+ import { composePlan, FAMILIES, isFamily, noFamilyAnswer, renderPlan, } from "../compose-context.js";
5
7
  import { readToken, resolveRegistry } from "../config.js";
6
8
  import { readEvents } from "../doctor/ledger.js";
7
9
  import { fileRequest } from "../doctor/requests.js";
@@ -14,6 +16,16 @@ import { installedSlug, remeasure } from "./sync.js";
14
16
  const send = (msg) => {
15
17
  process.stdout.write(`${JSON.stringify(msg)}\n`);
16
18
  };
19
+ /**
20
+ * A RESPOSTA QUE VEIO DO CONTRATO - o atalho, porque é o caso comum.
21
+ *
22
+ * `detail` fica de fora quando a própria resposta já nomeia a fonte (o token,
23
+ * o componente, a versão): repetir aquilo na linha de procedência é ruído.
24
+ */
25
+ const fromContract = (body, detail) => ({
26
+ body,
27
+ source: { kind: "contract", ...(detail ? { detail } : null) },
28
+ });
17
29
  /** A tool result is always text: readable by the model, and by a person
18
30
  * watching the transcript when something goes wrong. */
19
31
  const text = (body, isError = false) => ({
@@ -111,14 +123,14 @@ const TOOLS = [
111
123
  },
112
124
  {
113
125
  name: "playbook",
114
- description: "The skill playbooks (init, import, adapt) - SERVED, not shipped, so they are always current and your context only carries the step you are on. Call with { skill } to get the framing and a table of contents; then fetch ONLY the chapter for your current step with { skill, section }. Never fetch more than the step needs.",
126
+ description: "The skill playbooks (init, import, adapt, compose) - SERVED, not shipped, so they are always current and your context only carries the step you are on. Call with { skill } to get the framing and a table of contents; then fetch ONLY the chapter for your current step with { skill, section }. Never fetch more than the step needs. `compose` is the hierarchy to walk when this system has no recipe for what you were asked to build - the layers it DOES have, in order.",
115
127
  inputSchema: {
116
128
  type: "object",
117
129
  properties: {
118
130
  skill: {
119
131
  type: "string",
120
- enum: ["init", "import", "adapt"],
121
- description: "Which playbook.",
132
+ enum: ["init", "import", "adapt", "compose"],
133
+ description: "Which playbook. `compose` is the one for building something this system has no recipe for.",
122
134
  },
123
135
  section: {
124
136
  type: "string",
@@ -173,6 +185,24 @@ const TOOLS = [
173
185
  required: ["recipes"],
174
186
  },
175
187
  },
188
+ {
189
+ name: "compose_context",
190
+ description: "The layers to build something this system has NO recipe for, in one call: the recipes of that family to copy from, the tokens they already use with their values, the system's rules, and the floor and checklist for the family. YOU say which family it is - you just read the request, and a keyword guess of ours would be worse. Call this instead of asking for doctrine, vocabulary and each token separately; the laws of markup and the voice are named in the answer with the one fetch each needs.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ family: {
195
+ type: "string",
196
+ description: `Which family this behaves like: ${FAMILIES.join(", ")}. Pick by BEHAVIOUR, never by the name in the request. If a part fits none of them, send what you would call it and the answer says what to do.`,
197
+ },
198
+ intent: {
199
+ type: "string",
200
+ description: "What you are building, in a few words - it is echoed back so your summary can name it.",
201
+ },
202
+ },
203
+ required: ["family"],
204
+ },
205
+ },
176
206
  {
177
207
  name: "request_component",
178
208
  description: "File a component request when nothing in the catalogue covers what you need. This is the OTHER HALF of the refusal rule: you already say which entry you considered and why it did not fit - said in chat, that reasoning evaporates; filed here, it becomes the queue the system's author works from. File it at the moment you build the workaround, while the reasoning is still yours.",
@@ -254,7 +284,10 @@ export const MCP_TOOL_COUNT = TOOLS.length;
254
284
  async function checkFile(root, path) {
255
285
  const { table } = await loadSystem(root);
256
286
  if (table.byName.size === 0)
257
- return "No design system installed here, so there is nothing to check against. Run `synthesisui init` or `synthesisui adopt`.";
287
+ return {
288
+ body: "No design system installed here, so there is nothing to check against. Run `synthesisui init` or `synthesisui adopt`.",
289
+ source: { kind: "none", reason: "system", detail: "no system installed" },
290
+ };
258
291
  const scope = resolve(root, path);
259
292
  const reports = [];
260
293
  for await (const file of walkAll([scope])) {
@@ -263,7 +296,7 @@ async function checkFile(root, path) {
263
296
  reports.push(scanSource(relative(root, file), src, table));
264
297
  }
265
298
  if (reports.length === 0)
266
- return `Nothing readable at ${path}.`;
299
+ return fromContract(`Nothing readable at ${path}.`);
267
300
  const d = diagnose(reports);
268
301
  const out = [
269
302
  `${table.name ?? table.slug}: ${d.coverage}% of design values come from the system.`,
@@ -284,12 +317,16 @@ async function checkFile(root, path) {
284
317
  }
285
318
  if (d.findings.length === 0 && phantoms.length === 0)
286
319
  out.push("", "Nothing to fix here.");
287
- return out.join("\n");
320
+ return fromContract(out.join("\n"));
288
321
  }
289
322
  async function findToken(root, value) {
290
323
  const { table } = await loadSystem(root);
291
- if (table.byName.size === 0)
292
- return "No design system installed here.";
324
+ if (table.byName.size === 0) {
325
+ return {
326
+ body: "No design system installed here.",
327
+ source: { kind: "none", reason: "system", detail: "no system installed" },
328
+ };
329
+ }
293
330
  /**
294
331
  * O NOME DELE PRIMEIRO, como no doctor (`nameToWrite`): `loadSystem` já devolve a tabela COM os
295
332
  * aliases dele (`withTheirNames`) e este era o único leitor que nunca os consultava. Sem o `kind`
@@ -306,16 +343,27 @@ async function findToken(root, value) {
306
343
  const exact = tokenFor(table, value);
307
344
  if (exact) {
308
345
  const shown = theirNameFor(value) ?? exact;
309
- return `${value} is ${shown} in this project. Use var(${shown}).`;
346
+ return fromContract(`${value} is ${shown} in this project. Use var(${shown}).`);
310
347
  }
311
348
  const near = nearestToken(table, value);
312
349
  if (near) {
313
350
  const shown = theirNameFor(near.value) ?? near.name;
314
- return `No token holds ${value}. The closest is ${shown} at ${near.value}. If that is what you meant, use it - if it genuinely is not, say so rather than inventing a token.`;
351
+ /** Perto não é o valor: o sistema NÃO tem este, e a métrica precisa saber. */
352
+ return {
353
+ body: `No token holds ${value}. The closest is ${shown} at ${near.value}. If that is what you meant, use it - if it genuinely is not, say so rather than inventing a token.`,
354
+ source: {
355
+ kind: "none",
356
+ reason: "token",
357
+ detail: `no token for ${value}`,
358
+ },
359
+ };
315
360
  }
316
361
  // Same words the managed block uses. A refusal is only useful if it is the
317
362
  // same refusal every time.
318
- return `No token in this system holds ${value}, and nothing is close. Do NOT invent one. Say which value you need and what you would call it, and let a person decide.`;
363
+ return {
364
+ body: `No token in this system holds ${value}, and nothing is close. Do NOT invent one. Say which value you need and what you would call it, and let a person decide.`,
365
+ source: { kind: "none", reason: "token", detail: `no token for ${value}` },
366
+ };
319
367
  }
320
368
  /**
321
369
  * A DOUTRINA DO SISTEMA - as regras e a filosofia, servidas em vez de materializadas.
@@ -801,18 +849,28 @@ async function validateRecipes(root, entries) {
801
849
  * had 26 names against a catalogue of 41.
802
850
  */
803
851
  /**
804
- * O CONTADOR DO AGENTE, fire-and-forget: a tela do UI Kit mostra "vezes que esta receita
805
- * foi consultada pelo Agente", e este é o único ponto onde a consulta acontece de
806
- * verdade. Nunca bloqueia a resposta: sem sessão ou sem rede, não conta e não quebra -
807
- * telemetria que atrasa a ferramenta é telemetria que alguém desliga.
852
+ * O CONTADOR DO AGENTE, fire-and-forget e agora CENTRAL.
853
+ *
854
+ * Ele nasceu para uma tela: o UI Kit mostra "vezes que esta receita foi consultada pelo
855
+ * Agente". E era chamado à mão em duas ferramentas de treze, o que quer dizer que onze
856
+ * ferramentas nunca contaram nada - inclusive a única que sabe dizer que o sistema NÃO
857
+ * tinha o que foi pedido.
858
+ *
859
+ * Agora o `callTool` o chama uma vez, para toda ferramenta, com a procedência da
860
+ * resposta. O dono foi explícito sobre por que centralizar (18/08): *"isso vira
861
+ * comportamento padrão e não depende de disciplina futura."* Um contador chamado dentro
862
+ * de um `case` é um contador que a próxima ferramenta esquece.
863
+ *
864
+ * Nunca bloqueia a resposta: sem sessão ou sem rede, não conta e não quebra - telemetria
865
+ * que atrasa a ferramenta é telemetria que alguém desliga.
808
866
  */
809
- function countAgentRead(root, component, action,
867
+ function recordAgentCall(root, call,
810
868
  /** A versão que está reportando - ver `repoStateOf`. Ausente = não reporta estado. */
811
869
  cli) {
812
870
  void (async () => {
813
871
  try {
814
872
  const token = await readToken();
815
- if (!token || !component)
873
+ if (!token)
816
874
  return;
817
875
  // O slug vem do .lock do sistema instalado - a mesma resolução do sync.
818
876
  const dsDir = join(root, "_synthesisui", "ds");
@@ -853,8 +911,10 @@ cli) {
853
911
  */
854
912
  body: JSON.stringify({
855
913
  slug,
856
- component,
857
- action,
914
+ tool: toolSlug(call.tool),
915
+ source: provenanceAction(call.source),
916
+ ...(call.component ? { component: call.component } : {}),
917
+ ...(call.action ? { action: call.action } : {}),
858
918
  ...(repo ? { repo } : {}),
859
919
  }),
860
920
  });
@@ -912,7 +972,31 @@ async function describeComponent(root, name) {
912
972
  break;
913
973
  }
914
974
  if (!recipe) {
915
- return `This system has no component called "${name}". Run list_components to see what it does have - and if nothing covers what you need, file request_component rather than writing one from scratch.`;
975
+ /**
976
+ * O EVENTO MAIS VALIOSO DA PLATAFORMA, e até 18/08 ele não era contado.
977
+ *
978
+ * Aqui é onde o agente descobre que vai ter que improvisar. A frase já
979
+ * existia; o que não existia era o REGISTRO - então ninguém sabia quantas
980
+ * vezes, nem para quê. É este número que decide se compor conhecimento
981
+ * vale ser construído, e para quais famílias primeiro.
982
+ */
983
+ return {
984
+ /**
985
+ * ANTES ESTA FRASE TERMINAVA EM "não escreva do zero" E DEIXAVA O AGENTE
986
+ * SOZINHO - e o `request_component` completava com "siga com a sua solução
987
+ * alternativa". Juntas, elas diziam à esteira para registrar o improviso
988
+ * em vez de guiá-lo (dono, 18/08).
989
+ *
990
+ * Não ter a receita não é cair no zero: as camadas base existem, e agora
991
+ * há um playbook que as percorre em ordem. A frase aponta para ele.
992
+ */
993
+ body: `This system has no component called "${name}". Run list_components to see what it does have - a near miss still means composing WITH it rather than around it.\n\nIf nothing covers it, do not write from scratch and do not improvise: call playbook { "skill": "compose" }. It walks what this system DOES have, in order - its rules and voice, its tokens, the family this belongs to and what that family requires, and the laws that decide the markup. File request_component along the way so the gap becomes a decision somebody can make.`,
994
+ source: {
995
+ kind: "none",
996
+ reason: "recipe",
997
+ detail: `no recipe for "${name}"`,
998
+ },
999
+ };
916
1000
  }
917
1001
  const out = [
918
1002
  `${name}${recipe.description ? ` - ${recipe.description}` : ""}`,
@@ -1036,53 +1120,168 @@ async function describeComponent(root, name) {
1036
1120
  if (laws.length === 0 && needed.length === 0) {
1037
1121
  out.push("", "No rules govern this component yet. Build with it, and if you find yourself working around it, file request_component with the reasoning.");
1038
1122
  }
1039
- return out.join("\n");
1123
+ return fromContract(out.join("\n"));
1040
1124
  }
1041
1125
  async function addComponent(root, name) {
1042
1126
  const { table } = await loadSystem(root);
1043
- if (!table.slug)
1044
- return "No design system installed here.";
1127
+ if (!table.slug) {
1128
+ return {
1129
+ body: "No design system installed here.",
1130
+ source: { kind: "none", reason: "system", detail: "no system installed" },
1131
+ };
1132
+ }
1045
1133
  const { out } = await capturing(() => component(table.slug, name, { dir: root }));
1046
- return `${out}\n\nIt is real code in this project now - import it and extend it rather than writing your own.`;
1134
+ return fromContract(`${out}\n\nIt is real code in this project now - import it and extend it rather than writing your own.`);
1047
1135
  }
1048
1136
  // ── The protocol ─────────────────────────────────────────────────────────────
1137
+ /**
1138
+ * AS FERRAMENTAS QUE FALAM DE UM COMPONENTE PELO NOME - as únicas cujo alvo é
1139
+ * um slug de receita, e por isso as únicas que gravam o alvo.
1140
+ *
1141
+ * Um valor como `#2563eb` (o alvo de `find_token`) não é nome de componente e
1142
+ * seria recusado pela rota, que valida a coluna como slug. Então o alvo só
1143
+ * viaja quando é um: para o resto, o eixo da procedência é a própria
1144
+ * ferramenta.
1145
+ */
1146
+ const BY_COMPONENT = {
1147
+ describe_component: "describe",
1148
+ add_component: "add",
1149
+ };
1150
+ /**
1151
+ * O ÚNICO PONTO DE INSTRUMENTAÇÃO - e é isto que o dono pediu (18/08): *"vira
1152
+ * comportamento padrão e não depende de disciplina futura."*
1153
+ *
1154
+ * A ferramenta responde, a chamada é registrada com a procedência daquela
1155
+ * resposta, e a linha `Source:` é colada ao texto a partir do MESMO dado que
1156
+ * foi gravado. Não há como o número e a frase discordarem, porque não há duas
1157
+ * fontes.
1158
+ *
1159
+ * Uma ferramenta nova entra no switch e passa a contar sem tocar nesta função.
1160
+ */
1049
1161
  async function callTool(root, name, args,
1050
- /** A versão que reporta o estado do repo no ping - ver `countAgentRead`. */
1162
+ /** A versão que reporta o estado do repo no ping - ver `repoStateOf`. */
1051
1163
  cli) {
1164
+ const answer = await runTool(root, name, args);
1165
+ const gesture = BY_COMPONENT[name];
1166
+ const target = gesture
1167
+ ? String(args.name ?? "")
1168
+ .trim()
1169
+ .toLowerCase()
1170
+ : "";
1171
+ recordAgentCall(root, {
1172
+ tool: name,
1173
+ source: answer.source,
1174
+ ...(gesture ? { action: gesture } : {}),
1175
+ ...(target ? { component: target } : {}),
1176
+ }, cli);
1177
+ return text(withSource(answer), answer.isError);
1178
+ }
1179
+ /**
1180
+ * O SWITCH PURO - ele resolve a ferramenta e devolve a resposta COM a
1181
+ * procedência. Nenhuma linha dele conta nada: quem instrumenta é `callTool`,
1182
+ * uma vez, para todas.
1183
+ */
1184
+ async function runTool(root, name, args) {
1052
1185
  switch (name) {
1053
1186
  case "check_file":
1054
- return text(await checkFile(root, String(args.path ?? ".")));
1187
+ return checkFile(root, String(args.path ?? "."));
1055
1188
  case "find_token":
1056
- return text(await findToken(root, String(args.value ?? "")));
1189
+ return findToken(root, String(args.value ?? ""));
1057
1190
  case "system_doctrine":
1058
- return text(await systemDoctrine(root));
1191
+ return fromContract(await systemDoctrine(root));
1059
1192
  case "list_components":
1060
- return text(await listComponents(root));
1193
+ return fromContract(await listComponents(root));
1061
1194
  case "describe_component":
1062
- countAgentRead(root, String(args.name ?? ""), "describe", cli);
1063
- return text(await describeComponent(root, String(args.name ?? "")));
1195
+ return describeComponent(root, String(args.name ?? ""));
1064
1196
  case "add_component":
1065
- countAgentRead(root, String(args.name ?? ""), "add", cli);
1066
- return text(await addComponent(root, String(args.name ?? "")));
1197
+ return addComponent(root, String(args.name ?? ""));
1067
1198
  case "playbook": {
1068
1199
  const skill = String(args?.skill ?? "");
1069
1200
  const section = args?.section ? String(args.section) : undefined;
1070
- return text(await playbook(skill, section));
1201
+ return fromContract(await playbook(skill, section));
1071
1202
  }
1072
1203
  case "recipe_vocabulary":
1073
- return text(await recipeVocabulary());
1204
+ return fromContract(await recipeVocabulary());
1074
1205
  case "validate_recipe": {
1075
1206
  // Same resolution as the batch: an `at` the validator cannot see reads as
1076
1207
  // an empty node and earns a false floor verdict (dono, 01/08).
1077
1208
  const [only] = await resolveAtNodes(root, [
1078
1209
  { name: String(args.name ?? "this component"), recipe: args.recipe },
1079
1210
  ]);
1080
- return text(await validateRecipe(String(args.name ?? "this component"), only?.recipe ?? args.recipe));
1211
+ return fromContract(await validateRecipe(String(args.name ?? "this component"), only?.recipe ?? args.recipe));
1081
1212
  }
1082
1213
  case "validate_recipes":
1083
- return text(await validateRecipes(root, Array.isArray(args.recipes)
1214
+ return fromContract(await validateRecipes(root, Array.isArray(args.recipes)
1084
1215
  ? args.recipes
1085
1216
  : []));
1217
+ case "compose_context": {
1218
+ /**
1219
+ * A ORQUESTRAÇÃO - e o que ela reúne é do SISTEMA DELE, com o nosso
1220
+ * esqueleto de julgamento por cima.
1221
+ *
1222
+ * Uma chamada em vez de cinco, e a procedência disto é `composed`: o
1223
+ * primeiro caminho da esteira que emite esse kind, que até aqui existia no
1224
+ * vocabulário e não em resposta nenhuma.
1225
+ */
1226
+ const asked = String(args.family ?? "")
1227
+ .trim()
1228
+ .toLowerCase();
1229
+ const intent = String(args.intent ?? "").trim();
1230
+ if (!isFamily(asked)) {
1231
+ return {
1232
+ body: noFamilyAnswer(asked || "(nothing)"),
1233
+ source: {
1234
+ kind: "none",
1235
+ reason: "family",
1236
+ detail: `no family fits "${asked}"`,
1237
+ },
1238
+ };
1239
+ }
1240
+ const { documents, table, doctrines, requires } = await loadSystem(root);
1241
+ if (documents.length === 0) {
1242
+ return {
1243
+ body: "No design system installed here, so there is nothing to compose from. Run `synthesisui add <slug>` first - composing against nothing would be inventing.",
1244
+ source: {
1245
+ kind: "none",
1246
+ reason: "system",
1247
+ detail: "no system installed",
1248
+ },
1249
+ };
1250
+ }
1251
+ const plan = composePlan({
1252
+ documents,
1253
+ table,
1254
+ rules: doctrines.flatMap((d) => d.rules),
1255
+ /**
1256
+ * A regra tem a FRASE e o PACOTE em campos separados (`text`,
1257
+ * `requires`) - mandar a frase sozinha esconderia o que instalar, e
1258
+ * mandar só o pacote esconderia o porquê.
1259
+ */
1260
+ requires: requires.map((r) => ({
1261
+ rule: r.requires ? `${r.requires} - ${r.text}` : r.text,
1262
+ })),
1263
+ family: asked,
1264
+ intent,
1265
+ });
1266
+ /** O checklist é servido; sem rede o resto do plano continua valendo. */
1267
+ const answer = await askCatalogue("vocabulary");
1268
+ const v = answer.ok
1269
+ ? answer.body
1270
+ : null;
1271
+ const vocabulary = v
1272
+ ? {
1273
+ floor: v.floor?.[asked] ?? [],
1274
+ checklist: v.checklist?.[asked] ?? [],
1275
+ }
1276
+ : null;
1277
+ return {
1278
+ body: renderPlan(plan, vocabulary),
1279
+ source: {
1280
+ kind: "composed",
1281
+ detail: `${asked} · ${plan.siblings.length} recipe${plan.siblings.length === 1 ? "" : "s"} and ${plan.tokens.length} token${plan.tokens.length === 1 ? "" : "s"} from this system`,
1282
+ },
1283
+ };
1284
+ }
1086
1285
  case "request_component": {
1087
1286
  const r = await fileRequest(root, {
1088
1287
  kind: "component",
@@ -1091,7 +1290,7 @@ cli) {
1091
1290
  considered: args.considered ? String(args.considered) : undefined,
1092
1291
  file: args.file ? String(args.file) : undefined,
1093
1292
  });
1094
- return text(`Filed as ${r.id}. It shows up in \`synthesisui doctor\` until a person authors it or closes it - keep building with your workaround, and say in your summary that the request exists.`);
1293
+ return fromContract(`Filed as ${r.id}. It shows up in \`synthesisui doctor\` until a person authors it or closes it.\n\nNow build it properly rather than working around it: playbook { "skill": "compose" } walks the layers this system already declares, so what you hand back matches it even without a recipe. Say in your summary that the request exists, and which parts were composed rather than taken from the system.`);
1095
1294
  }
1096
1295
  case "refresh_system": {
1097
1296
  /**
@@ -1111,10 +1310,10 @@ cli) {
1111
1310
  */
1112
1311
  const slug = await installedSlug(root);
1113
1312
  if (!slug)
1114
- return text("No system installed here that belongs to a dashboard row, so there is nothing to refresh. `synthesisui add <slug>` installs one; an adopted system is described here and owned elsewhere.");
1313
+ return fromContract("No system installed here that belongs to a dashboard row, so there is nothing to refresh. `synthesisui add <slug>` installs one; an adopted system is described here and owned elsewhere.");
1115
1314
  const token = await readToken();
1116
1315
  if (!token)
1117
- return text("Not signed in, so nothing could be sent. A person has to run `synthesisui login` once in the terminal - it opens a browser, and I cannot complete it for them.");
1316
+ return fromContract("Not signed in, so nothing could be sent. A person has to run `synthesisui login` once in the terminal - it opens a browser, and I cannot complete it for them.");
1118
1317
  const { out } = await capturing(() => remeasure({
1119
1318
  root,
1120
1319
  base: resolveRegistry(),
@@ -1137,7 +1336,7 @@ cli) {
1137
1336
  const behind = pinned && pinned !== VERSION ? pinned : null;
1138
1337
  const said = out.trim() ||
1139
1338
  "Nothing was reported by the refresh - which means it found nothing to measure.";
1140
- return text(behind
1339
+ return fromContract(behind
1141
1340
  ? `${said}\n\nUPDATE THE DESIGN SYSTEM BEFORE CONTINUING.\nThe check that runs after your writes is pinned to ${behind} and these tools are ${VERSION}.\n\n npx synthesisui@latest connect\n\nAsk the person, run it for them, and then say this: close and reopen the editor. The hook and the tools only rewire on a new session, so nothing you write before that is checked by the reader you are holding.`
1142
1341
  : said);
1143
1342
  }
@@ -1159,7 +1358,7 @@ cli) {
1159
1358
  file: args.file ? String(args.file) : undefined,
1160
1359
  ...(area ? { area } : null),
1161
1360
  });
1162
- return text(area
1361
+ return fromContract(area
1163
1362
  ? `Filed as ${r.id} and routed to the synthesisui platform team - the system's contract already promises this and the shipped css does not deliver it. No one needs to act: it closes itself when an update lands. Keep the quiet base meanwhile.`
1164
1363
  : `Filed as ${r.id}. Do not add the token yourself - the request shows up in \`synthesisui doctor\` for a person to decide.`);
1165
1364
  }
@@ -1179,10 +1378,14 @@ cli) {
1179
1378
  considered: args.considered ? String(args.considered) : undefined,
1180
1379
  file: args.file ? String(args.file) : undefined,
1181
1380
  });
1182
- return text(`Filed as ${r.id}. Do not adopt the convention as if it were a rule - it shows up in \`synthesisui doctor\` and travels to the system's queue, for the owner to make it a rule or decline it.`);
1381
+ return fromContract(`Filed as ${r.id}. Do not adopt the convention as if it were a rule - it shows up in \`synthesisui doctor\` and travels to the system's queue, for the owner to make it a rule or decline it.`);
1183
1382
  }
1184
1383
  default:
1185
- return text(`No tool named ${name}.`, true);
1384
+ return {
1385
+ body: `No tool named ${name}.`,
1386
+ source: { kind: "none", reason: "tool", detail: "unknown tool" },
1387
+ isError: true,
1388
+ };
1186
1389
  }
1187
1390
  }
1188
1391
  /**
@@ -0,0 +1,210 @@
1
+ /**
2
+ * O CONTEXTO MÍNIMO PARA CONSTRUIR ALGO QUE O SISTEMA NÃO TEM.
3
+ *
4
+ * ─────────────────────────────────────────────────────────────────────────
5
+ * AS DUAS FONTES, E A PROPORÇÃO ENTRE ELAS SE INVERTE SOZINHA
6
+ *
7
+ * O material que enche este contexto é DELE - os tokens que as receitas dele já
8
+ * usam, as regras que ele declarou, as bibliotecas que o repositório dele
9
+ * carrega. É isso que faz o componente novo nascer parecido com o sistema dele
10
+ * em vez de parecido com o nosso gosto.
11
+ *
12
+ * O que é NOSSO é pequeno de propósito e não fala de nenhum componente
13
+ * específico: as sete famílias, o checklist de cada uma e as leis de marcação.
14
+ * Sem essa metade, um sistema de dois componentes - e existem quatro deles em
15
+ * produção, medido em 18/08 - não teria nada a oferecer justamente a quem mais
16
+ * precisa de orientação.
17
+ *
18
+ * Sistema maduro: "o seu input usa estes sete tokens, use os mesmos" (paridade).
19
+ * Sistema novo: "um campo precisa destas cinco coisas, o seu sistema ainda não
20
+ * nomeia três delas" (orientação, com a lacuna declarada).
21
+ *
22
+ * ─────────────────────────────────────────────────────────────────────────
23
+ * A FAMÍLIA VEM DE QUEM PEDE, e a plataforma valida (decisão do dono, 18/08)
24
+ *
25
+ * Quem chama esta ferramenta é um modelo que acabou de ler o pedido em linguagem
26
+ * natural. Ele classifica melhor que qualquer heurística de palavras que a gente
27
+ * escrevesse aqui, e classificar com IA hospedada gastaria Ink para responder o
28
+ * que já foi respondido do outro lado da chamada.
29
+ *
30
+ * Então o agente informa a família, a gente confere contra o contrato, e quando
31
+ * ela não é nenhuma das sete isso não é erro: é uma lacuna de FAMÍLIA, que é uma
32
+ * decisão diferente de um componente faltando.
33
+ */
34
+ /** As sete que o contrato conhece - o mesmo enum de `componentPreviewKind`. */
35
+ export const FAMILIES = [
36
+ "action",
37
+ "control",
38
+ "field",
39
+ "surface",
40
+ "pill",
41
+ "indicator",
42
+ "text",
43
+ ];
44
+ export function isFamily(value) {
45
+ return FAMILIES.includes(value);
46
+ }
47
+ /**
48
+ * OS TOKENS QUE ESTA FAMÍLIA JÁ USA, lidos das receitas dela.
49
+ *
50
+ * Uma referência de token num documento é `{color.semantic.canvas}` - a mesma
51
+ * forma que o compilador resolve. Então "quais tokens um campo usa neste
52
+ * sistema" é uma pergunta que o próprio documento responde, sem tabela nossa e
53
+ * sem palpite.
54
+ *
55
+ * MEDIDO no signalui (37 componentes, 74 referências no total): field 37,
56
+ * surface 37, control 33, action 24, pill 17, text 15, indicator 12. O corte por
57
+ * volume é modesto num sistema pequeno - o valor aqui não é economia de
58
+ * contexto, é PARIDADE: o agente copia o que existe em vez de escolher.
59
+ */
60
+ export function tokensOfFamily(documents, family) {
61
+ const refs = new Set();
62
+ for (const doc of documents) {
63
+ const components = doc
64
+ .components;
65
+ if (!components)
66
+ continue;
67
+ for (const recipe of Object.values(components)) {
68
+ if (recipe?.preview?.kind !== family)
69
+ continue;
70
+ for (const ref of JSON.stringify(recipe).match(/\{[a-z0-9.]+\}/gi) ??
71
+ []) {
72
+ /**
73
+ * `{onPressedChange}` e afins aparecem no mesmo formato e NÃO são
74
+ * tokens - são nomes de prop que a receita cita. Um token tem caminho:
75
+ * família, e depois pelo menos um degrau.
76
+ */
77
+ if (ref.includes("."))
78
+ refs.add(ref);
79
+ }
80
+ }
81
+ }
82
+ return [...refs].sort();
83
+ }
84
+ /** As receitas desta família que o sistema já tem - de onde copiar. */
85
+ export function recipesOfFamily(documents, family) {
86
+ const out = [];
87
+ for (const doc of documents) {
88
+ const components = doc
89
+ .components;
90
+ if (!components)
91
+ continue;
92
+ for (const [name, recipe] of Object.entries(components)) {
93
+ if (recipe?.preview?.kind === family) {
94
+ out.push({
95
+ name,
96
+ ...(recipe.description ? { description: recipe.description } : null),
97
+ });
98
+ }
99
+ }
100
+ }
101
+ return out.sort((a, b) => a.name.localeCompare(b.name));
102
+ }
103
+ /**
104
+ * O VALOR DE CADA REFERÊNCIA, quando a tabela o conhece.
105
+ *
106
+ * `{radius.md}` sozinho não diz nada a quem vai escrever css; `{radius.md} =
107
+ * 0.5rem` diz. A tabela de tokens é a mesma que o `find_token` consulta, então
108
+ * a resposta aqui e a resposta dele nunca divergem.
109
+ */
110
+ export function valueOf(table, ref) {
111
+ const path = ref.slice(1, -1);
112
+ /** `color.semantic.canvas` -> `--ds-color-semantic-canvas`. */
113
+ const name = `--ds-${path.replace(/\./g, "-")}`;
114
+ return table.byName.get(name) ?? null;
115
+ }
116
+ /**
117
+ * A MONTAGEM - pura, e é por isso que ela mora fora do handler.
118
+ *
119
+ * O handler faz I/O (`loadSystem`, a rede do catalogue). O que decide o que
120
+ * entra no contexto é isto, e é o que um spec consegue exercer com um documento
121
+ * de verdade sem subir nada.
122
+ */
123
+ export function composePlan(input) {
124
+ const refs = tokensOfFamily(input.documents, input.family);
125
+ return {
126
+ family: input.family,
127
+ intent: input.intent,
128
+ siblings: recipesOfFamily(input.documents, input.family),
129
+ tokens: refs.map((ref) => ({ ref, value: valueOf(input.table, ref) })),
130
+ rules: [...input.rules],
131
+ requires: [...input.requires],
132
+ };
133
+ }
134
+ /**
135
+ * O PLANO COMO O AGENTE O LÊ - e a ordem é a das camadas, não a da comodidade.
136
+ *
137
+ * Cada bloco diz de onde veio: o que é do sistema dele, o que é checklist nosso,
138
+ * e o que ninguém decidiu ainda. É a mesma gramática da linha `Source:` que toda
139
+ * resposta carrega, um nível abaixo - e é o que permite ao agente relatar
140
+ * procedência por parte em vez de por resposta.
141
+ */
142
+ export function renderPlan(plan,
143
+ /** O checklist e o piso desta família, servidos do catálogo. `null` sem rede. */
144
+ vocabulary) {
145
+ const out = [
146
+ `COMPOSING: ${plan.intent || plan.family}`,
147
+ `FAMILY: ${plan.family} - every part below answers to this family's checklist.`,
148
+ "",
149
+ ];
150
+ out.push("── FROM THIS SYSTEM ────────────────────────────────────────");
151
+ if (plan.siblings.length > 0) {
152
+ out.push(`Recipes in this family, to copy from rather than to choose against (${plan.siblings.length}):`, ...plan.siblings.map((s) => ` ${s.name}${s.description ? ` - ${s.description}` : ""}`));
153
+ }
154
+ else {
155
+ out.push("This system has NO recipe in this family yet. Nothing here to copy, so", "every decision below is one you are making for the first time - name them", "in your summary, and file what the system should have named.");
156
+ }
157
+ out.push("");
158
+ if (plan.tokens.length > 0) {
159
+ out.push(`Tokens those recipes already use (${plan.tokens.length}) - use these before any literal:`, ...plan.tokens.map((t) => ` ${t.ref}${t.value ? ` = ${t.value}` : " (name declared, value not on disk)"}`));
160
+ }
161
+ else {
162
+ out.push("No token is used by this family yet. Ask `find_token` for each value you", "need, and file `request_token` for the ones nothing holds - never invent a name.");
163
+ }
164
+ if (plan.rules.length > 0) {
165
+ out.push("", `Rules - maximum authority, ${plan.rules.length} of them:`, ...plan.rules.map((r) => ` - ${r}`));
166
+ }
167
+ if (plan.requires.length > 0) {
168
+ out.push("", "Packages this system says the project needs:", ...plan.requires.map((r) => ` - ${r.rule}`));
169
+ }
170
+ out.push("", "── THE FLOOR FOR THIS FAMILY ───────────────────────────────");
171
+ if (vocabulary) {
172
+ if (vocabulary.floor.length > 0) {
173
+ out.push("Below this it cannot be told apart from the page:");
174
+ out.push(...vocabulary.floor.map((f) => ` ${f}`));
175
+ }
176
+ out.push("", "Work this list and say which items this system's own recipes answer:", ...vocabulary.checklist.map((c) => ` - ${c}`));
177
+ }
178
+ else {
179
+ out.push("The checklist is served from the platform and could not be reached right", "now. Everything above still holds - fetch it with `recipe_vocabulary`", "before you call the component finished.");
180
+ }
181
+ out.push("", "── WHAT IS NOT HERE ────────────────────────────────────────", "The laws that decide the markup (which tag, whose layout wins, why", "accessibility moves the ink) are one fetch away and not repeated here:", ' playbook { "skill": "compose", "section": "path-2-layer-3-the-laws-that-decide-the-markup" }', "", "The voice - how this product speaks - is not in this answer either. Call", "`system_doctrine` before writing any user-facing copy inside this component.", "", "When you are done: `validate_recipe` if you are writing one, then", "`request_component` for what the system should have had, and report which", "parts came from the system and which you composed.");
182
+ return out.join("\n");
183
+ }
184
+ /**
185
+ * A RESPOSTA QUANDO A FAMÍLIA NÃO É NENHUMA DAS SETE.
186
+ *
187
+ * Não é erro e não é uma lista de sugestões parecidas: é um fato sobre o
188
+ * contrato, e o pedido de quem o lê é diferente do pedido de um componente. Um
189
+ * gráfico, um mapa, uma grade de calendário não são `surface` mal classificado -
190
+ * forçá-los ali produziria um piso que não significa nada.
191
+ */
192
+ export function noFamilyAnswer(asked) {
193
+ return [
194
+ `"${asked}" is not one of the seven families this contract can draw: ${FAMILIES.join(", ")}.`,
195
+ "",
196
+ "That is a real answer, not a rejection. Two things are true at once:",
197
+ "",
198
+ " 1. Everything ABOVE the family layer still holds in full - the system's",
199
+ " rules, its tokens, and the laws of markup. Ask `find_token` per value,",
200
+ " `system_doctrine` for the rules, and fetch the laws chapter of the",
201
+ " compose playbook.",
202
+ " 2. What is missing is a KIND, not a component - and that is a different",
203
+ " decision for the system's owner. File it:",
204
+ " request_component with what the thing is and why no family fits.",
205
+ "",
206
+ "Then decompose: parts of it probably DO have families. A region that holds",
207
+ "things is a surface, a control inside it is a control - ask again for those",
208
+ "parts, and compose the rest without a family, saying so.",
209
+ ].join("\n");
210
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * O STUB DA SKILL DE COMPOSIÇÃO - o corpo é SERVIDO, como os outros três.
3
+ *
4
+ * ─────────────────────────────────────────────────────────────────────────
5
+ * POR QUE ESTA SKILL EXISTE (dono, 18/08)
6
+ *
7
+ * As três primeiras cobrem trazer um sistema para dentro e mantê-lo. Nenhuma
8
+ * cobria o gesto mais comum do dia a dia: alguém pede um componente que o
9
+ * sistema não tem.
10
+ *
11
+ * E o que a plataforma respondia nesse caso eram duas frases, medidas:
12
+ * `describe_component` dizia *"não escreva do zero"* e `request_component`
13
+ * dizia, na frase seguinte, *"siga com a sua solução alternativa"*. Ou seja, a
14
+ * esteira registrava que o agente improvisou em vez de guiar a composição - o
15
+ * oposto de uma hierarquia de conhecimento.
16
+ *
17
+ * A tese do dono: não ter a receita não é cair no zero. As camadas base
18
+ * existem - tokens, regras, a família e o que o tipo dela exige, as leis de
19
+ * marcação - e compor a partir delas é tão valioso quanto ter a receita pronta.
20
+ */
21
+ export const COMPOSE_SKILL_PATH = ".claude/skills/sui-compose/SKILL.md";
22
+ export const COMPOSE_SKILL = '---\nname: sui-compose\ndescription: Build ANY UI element the installed design system has no recipe for - without inventing values, tags or accessibility. Use whenever you are about to write a UI element from scratch, when `describe_component` answers that the system does not have it, or when what was asked for is a composite nobody has written yet - whatever it is called (an editor, a palette, a table, a chart, a layout region: the playbook does not care which). Walks the layers the system DOES have, in order, decomposes the request into families, and declares what it could not cover.\n---\n\n# Compose what the system does not have - served live\n\nThis playbook is served from the platform, not shipped in this file - it is\nalways current, and your context only carries the step you are on.\n\n1. Call the `playbook` tool on the `synthesisui` MCP server with\n { "skill": "compose" } - you get the framing and a table of contents.\n2. Fetch ONLY the chapter for the step you are on:\n { "skill": "compose", "section": "<id from the toc>" }. Never fetch more\n than the current step needs.\n3. Follow it exactly. When the step is done, fetch the next chapter.\n\nThe first chapter tells you which of the two paths you are on - the system has\na recipe, or nobody has written one yet - and the rest walk the layers in order.\n\nIf the tool answers that you are not signed in, run `npx synthesisui login`\nin the terminal and call it again. If the `synthesisui` MCP server is not\navailable at all, run `npx synthesisui connect`, restart the session, and\ninvoke this skill again.\n';
package/dist/skills.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ADAPT_SKILL, ADAPT_SKILL_PATH } from "./skill-adapt.js";
2
+ import { COMPOSE_SKILL, COMPOSE_SKILL_PATH } from "./skill-compose.js";
2
3
  import { IMPORT_SKILL, IMPORT_SKILL_PATH } from "./skill-import.js";
3
4
  import { INIT_SKILL, INIT_SKILL_PATH } from "./skill-init.js";
4
5
  /**
@@ -36,4 +37,20 @@ export const SKILLS = [
36
37
  label: "/sui-adapt",
37
38
  what: "one component against the system, and what to do about it",
38
39
  },
40
+ /**
41
+ * A DE CONSTRUÇÃO, e ela é a que responde ao pedido mais comum de todos:
42
+ * "faz um componente X" quando o sistema não tem X.
43
+ *
44
+ * As três acima são sobre o sistema - trazer, manter, conferir. Esta é sobre o
45
+ * TRABALHO de quem usa o sistema, e é a única que roda todo dia. Ela existe
46
+ * porque a resposta anterior a esse pedido era o agente improvisar sozinho: a
47
+ * esteira dizia "não escreva do zero" e, na frase seguinte, "siga com a sua
48
+ * solução alternativa" (dono, 18/08).
49
+ */
50
+ {
51
+ path: COMPOSE_SKILL_PATH,
52
+ source: COMPOSE_SKILL,
53
+ label: "/sui-compose",
54
+ what: "build what the system does not have, layer by layer",
55
+ },
39
56
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.243",
3
+ "version": "0.16.247",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {