synthesisui 0.16.159 → 0.16.161

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.
@@ -36,6 +36,22 @@ export async function hookCommand(root, version) {
36
36
  ? "npx --no-install synthesisui hook"
37
37
  : `npx synthesisui@${version} hook`;
38
38
  }
39
+ /**
40
+ * COMO A SESSÃO CHAMA O CLI, e por que este NÃO é pinado.
41
+ *
42
+ * O hook de escrita é pinado porque roda dezenas de vezes por sessão e um verificador que muda sob
43
+ * você a cada edit é indebugável. Este roda UMA vez, e o trabalho dele é justamente dizer o que está
44
+ * atrasado - inclusive o próprio CLI. Um `align` pinado no dia do `connect` não conhece nenhuma
45
+ * verificação escrita depois, e passa a garantir silêncio em vez de alinho.
46
+ *
47
+ * O custo do `npx` (720ms-1.5s, medido em 27/07) é pago uma vez na abertura, não por escrita.
48
+ */
49
+ export async function alignCommand(root) {
50
+ const local = join(root, "node_modules", ".bin", "synthesisui");
51
+ return (await exists(local))
52
+ ? "npx --no-install synthesisui align"
53
+ : "npx synthesisui@latest align";
54
+ }
39
55
  /**
40
56
  * Returns the command that IS in the file, not the one we would have written.
41
57
  *
@@ -211,9 +227,45 @@ export async function wireAgent(root, version, want) {
211
227
  command: hook.command,
212
228
  hook: hook.status,
213
229
  ...(hook.was ? { was: hook.was } : {}),
230
+ /**
231
+ * A VERIFICAÇÃO DE ABERTURA vai junto do hook de escrita, e por um motivo medido: as onze
232
+ * ferramentas MCP são todas PULL, o agente só descobre um desalinho se resolver perguntar, e o
233
+ * único hook instalado rodava DEPOIS de uma escrita - tarde demais para dizer "você não está
234
+ * logado" ou "este sistema não sabe de onde foi medido".
235
+ */
236
+ session: want.hook
237
+ ? await wireSessionStart(root, await alignCommand(root))
238
+ : "skipped",
214
239
  mcp: want.mcp ? await wireMcp(root, version) : "skipped",
215
240
  };
216
241
  }
242
+ /**
243
+ * O hook `SessionStart`, escrito com a mesma disciplina do de escrita: vizinho nenhum é tocado, e um
244
+ * comando escrito à mão fica como está. Só um `align` nosso apontando para outro lugar é substituído.
245
+ */
246
+ async function wireSessionStart(root, command) {
247
+ const dir = join(root, ".claude");
248
+ const path = join(dir, "settings.json");
249
+ const settings = await readJson(path);
250
+ const hooks = (settings.hooks ?? {});
251
+ const entries = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
252
+ for (const entry of entries)
253
+ for (const h of entry.hooks ?? []) {
254
+ const was = h.command ?? "";
255
+ if (!/synthesisui(@[\w.-]+)?\s+align/.test(was))
256
+ continue;
257
+ if (was === command)
258
+ return "already there";
259
+ h.command = command;
260
+ await mkdir(dir, { recursive: true });
261
+ await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: entries } }, null, 2)}\n`, "utf8");
262
+ return "updated";
263
+ }
264
+ entries.push({ hooks: [{ type: "command", command }] });
265
+ await mkdir(dir, { recursive: true });
266
+ await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: entries } }, null, 2)}\n`, "utf8");
267
+ return "added";
268
+ }
217
269
  /**
218
270
  * Whether THIS project already has the hook.
219
271
  *
@@ -4,10 +4,43 @@ import { syncClaudeMd } from "../claude-md.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.js";
6
6
  import { buildGuide } from "../guide.js";
7
+ import { censusScope } from "../measured-scope.js";
7
8
  import { body as line, section, snippet } from "../output.js";
8
9
  import { fetchDesignSystem } from "../registry.js";
9
10
  import { describeFiltered, ruleApplies, rulesForProject, } from "../rule-filter.js";
10
11
  import { detectStack } from "../stack.js";
12
+ /**
13
+ * QUAL METADE DESTA PASTA UM TIME COMMITA.
14
+ *
15
+ * Medido no repo do dono em 06/08: `_synthesisui/` não estava no gitignore E não tinha um único
16
+ * arquivo rastreado. Ou seja, a pasta existia só na máquina de quem rodou o import - todo clone, e
17
+ * todo colega, começava sem governança nenhuma, e nada dizia isso a ninguém.
18
+ *
19
+ * A divisão não é gosto, é o que cada arquivo É:
20
+ *
21
+ * .lock, ds/<slug>/** a IDENTIDADE e o CSS que a app importa. Sem eles o build de um colega
22
+ * não tem tokens. Versionar.
23
+ * census.json 0,5 MB, regravado inteiro a cada medição. Versionar é conflito toda vez.
24
+ * ledger.jsonl registro local, append-only, uma linha por escrita. Conflito garantido.
25
+ *
26
+ * Escrito uma vez e nunca reescrito: um arquivo que alguém ajustou é decisão dele.
27
+ */
28
+ async function writeGovernanceIgnore(projectRoot) {
29
+ const path = join(projectRoot, "_synthesisui", ".gitignore");
30
+ try {
31
+ await access(path);
32
+ return;
33
+ }
34
+ catch {
35
+ // não existe - escrever
36
+ }
37
+ await writeFile(path, "# Managed by synthesisui. The identity and the CSS are committed so a fresh\n" +
38
+ "# clone is governed; the measurement and the local record are not, because\n" +
39
+ "# both are rewritten in full on every run and would conflict on every merge.\n" +
40
+ "census.json\n" +
41
+ "ledger.jsonl\n" +
42
+ "not-expressed.md\n", "utf8");
43
+ }
11
44
  async function readRootLock(path) {
12
45
  try {
13
46
  return JSON.parse(await readFile(path, "utf8"));
@@ -62,14 +95,25 @@ export async function add(slug, opts) {
62
95
  `@import "./v${payload.version}/${filename}";\n`, "utf8");
63
96
  }
64
97
  // 5. root pointer
98
+ /**
99
+ * A medição FRESCA vence a cópia anterior, e por isso lê o censo em vez do lock: um `--scope` novo
100
+ * chega pelo censo que acabou de rodar, e ler o lock aqui congelaria o primeiro para sempre. Sem
101
+ * censo (um `add` puro, sem import antes), o que o lock já dizia é preservado.
102
+ */
103
+ const measured = await censusScope(projectRoot);
104
+ const scope = measured.system ?? prev?.scope ?? null;
105
+ const usage = measured.usage.length > 0 ? measured.usage : (prev?.usage ?? []);
65
106
  const lock = {
66
107
  slug: payload.slug,
67
108
  name: payload.name,
68
109
  version: payload.version,
69
110
  registry: base,
70
111
  fetchedAt: new Date().toISOString(),
112
+ ...(scope ? { scope } : {}),
113
+ ...(usage.length > 0 ? { usage } : {}),
71
114
  };
72
115
  await writeFile(rootLockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
116
+ await writeGovernanceIgnore(projectRoot);
73
117
  // 5b. governance rules (personal DS) → rules.md at the slug root (stable path,
74
118
  // highest authority; the GUIDE tells the agent to read it first)
75
119
  /**
@@ -0,0 +1,133 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { readEvents } from "../doctor/ledger.js";
5
+ import { measuredScope } from "../measured-scope.js";
6
+ async function locksIn(root) {
7
+ const dsDir = join(root, "_synthesisui", "ds");
8
+ const entries = await readdir(dsDir, { withFileTypes: true }).catch(() => []);
9
+ const out = [];
10
+ for (const entry of entries) {
11
+ if (!entry.isDirectory())
12
+ continue;
13
+ const raw = await readFile(join(dsDir, entry.name, ".lock"), "utf8").catch(() => null);
14
+ if (!raw)
15
+ continue;
16
+ try {
17
+ out.push(JSON.parse(raw));
18
+ }
19
+ catch {
20
+ // lock ilegível - o `status` já reporta isso com o nome do arquivo
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+ async function credentials(home) {
26
+ const raw = await readFile(join(home, ".synthesisui", "credentials.json"), "utf8").catch(() => null);
27
+ if (!raw)
28
+ return null;
29
+ try {
30
+ return JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * A metade que não depende de rede.
38
+ *
39
+ * Separada de propósito: ela é a que sempre roda, e é testável sem servidor nenhum.
40
+ */
41
+ export async function localMisalignments(root,
42
+ /**
43
+ * `home` é injetável porque a SESSÃO mora nele e não no repo - e uma verificação que lê o home da
44
+ * máquina que roda o teste não é verificável: ela passa ou falha conforme quem está logado ali.
45
+ */
46
+ opts = {}) {
47
+ const { cli, home = homedir() } = opts;
48
+ const out = [];
49
+ const locks = (await locksIn(root)).filter((l) => l.slug && !l.adopted);
50
+ /** Sem sistema instalado não há alinho a cobrar - não é desalinho, é um repo sem DS. */
51
+ if (locks.length === 0)
52
+ return out;
53
+ if (locks.length > 1)
54
+ out.push({
55
+ says: `${locks.length} design systems are installed here (${locks
56
+ .map((l) => l.slug)
57
+ .join(", ")}) - commands act on the first, so one of them is not being governed`,
58
+ });
59
+ const lock = locks[0];
60
+ const creds = await credentials(home);
61
+ if (!creds)
62
+ out.push({
63
+ says: "No session on this machine. A token belongs to the machine, not to the repo, so a fresh clone always lands here.",
64
+ run: "npx synthesisui login",
65
+ });
66
+ else if (creds.registry && lock.registry && creds.registry !== lock.registry)
67
+ out.push({
68
+ says: `Your session is for ${creds.registry} and this system came from ${lock.registry}. That answers 401, which reads as an expired session on a host that never issued one.`,
69
+ run: `npx synthesisui login --registry ${lock.registry}`,
70
+ });
71
+ /**
72
+ * O ESCOPO, que é o desalinho mais caro e o mais silencioso: sem ele o `sync` mede o repo inteiro
73
+ * e manda os componentes de todos os apps para um sistema que é uma biblioteca (06/08, 338 num
74
+ * sistema de 36).
75
+ */
76
+ const scope = await measuredScope(root);
77
+ if (!scope.system && scope.usage.length === 0)
78
+ out.push({
79
+ says: `"${lock.slug}" does not record where it was measured, so a re-measure would read this whole repo instead of the folder the system came from. Sync asks once and remembers the answer.`,
80
+ /**
81
+ * O COMANDO QUE ELA JÁ RODA, e nunca um sexto.
82
+ *
83
+ * Isto dizia `import --dry --scope <folder>`, que não está no vocabulário de quem usa isto -
84
+ * `connect`, `login`, `doctor`, `sync`, `upgrade`, e a skill. O `align` é a única coisa que
85
+ * fala com a pessoa sem ela pedir, e falar ensinando uma palavra que ela nunca viu transfere
86
+ * para ela um problema que era nosso. O `sync` absorveu o caso - ver `askForScope`.
87
+ */
88
+ run: "npx synthesisui sync",
89
+ });
90
+ const events = await readEvents(root).catch(() => []);
91
+ if (events.length > 0)
92
+ out.push({
93
+ says: `${events.length} checked write${events.length === 1 ? "" : "s"} recorded here and not sent - the platform is scoring this repo on older evidence.`,
94
+ run: "npx synthesisui sync",
95
+ });
96
+ if (cli && lock.version == null)
97
+ out.push({
98
+ says: `"${lock.slug}" has no version recorded, so nothing can tell whether the CSS in this repo is the one the platform publishes.`,
99
+ run: "npx synthesisui upgrade",
100
+ });
101
+ return out;
102
+ }
103
+ /**
104
+ * A linha que a sessão vê.
105
+ *
106
+ * Uma frase por desalinho e o comando ao lado - sem cabeçalho, sem contagem, sem moldura. Isto é
107
+ * injetado no contexto de um agente, e cada linha decorativa é contexto que não é sobre o trabalho
108
+ * dele.
109
+ */
110
+ export function describeMisalignments(items) {
111
+ if (items.length === 0)
112
+ return "";
113
+ return [
114
+ "Before this session starts, this environment is out of alignment with the design system that governs it:",
115
+ ...items.map((m) => ` - ${m.says}${m.run ? `\n ${m.run}` : ""}`),
116
+ ].join("\n");
117
+ }
118
+ /**
119
+ * `synthesisui align` - o comando que o hook de sessão roda.
120
+ *
121
+ * Sai silencioso e com código 0 quando está tudo alinhado, porque um hook que fala toda vez deixa de
122
+ * ser lido. E nunca falha: um ambiente que não dá para verificar não pode impedir alguém de
123
+ * trabalhar.
124
+ */
125
+ export async function align(opts) {
126
+ const root = resolve(opts.dir ?? process.cwd());
127
+ const items = await localMisalignments(root, {
128
+ ...(opts.cli ? { cli: opts.cli } : {}),
129
+ }).catch(() => []);
130
+ const text = describeMisalignments(items);
131
+ if (text)
132
+ console.log(text);
133
+ }
@@ -50,6 +50,15 @@ export async function connect(opts) {
50
50
  `✓ .claude/settings.json the check moved from ${wired.was?.match(/synthesisui@(\d+\.\d+\.\d+)/)?.[1] ?? "an older version"} to this one`
51
51
  : "· .claude/settings.json already had it"));
52
52
  console.log(snippet([wired.command]));
53
+ /**
54
+ * A SEGUNDA COSTURA, dita por nome. As onze ferramentas MCP são PULL e o hook de escrita roda
55
+ * DEPOIS de uma escrita - nenhum dos dois chega a tempo de dizer "você não está logado nesta
56
+ * máquina" ou "este sistema não sabe de onde foi medido". O `SessionStart` é o único que chega.
57
+ */
58
+ if (wired.session !== "skipped")
59
+ console.log(body(wired.session === "already there"
60
+ ? "· .claude/settings.json the session check was already there"
61
+ : `✓ .claude/settings.json every session now opens with what this environment is missing${wired.session === "updated" ? ", pointed at this version" : ""}`));
53
62
  }
54
63
  /**
55
64
  * OS CAMINHOS DE MCP, POR NOME - e o número vem da LISTA, não da minha memória.
@@ -79,7 +79,7 @@ const MAX_PER_KIND = {
79
79
  * comes from wherever the vocabulary is centralised, and each app is a consumer
80
80
  * measured against it.
81
81
  */
82
- async function siblingProjects(root) {
82
+ export async function siblingProjects(root) {
83
83
  const apps = [];
84
84
  const shared = [];
85
85
  for (const group of ["apps", "packages", "libs"]) {
@@ -3,9 +3,10 @@ import { join, resolve } from "node:path";
3
3
  import { readToken, resolveRegistry } from "../config.js";
4
4
  import { readEvents } from "../doctor/ledger.js";
5
5
  import { checkableName, closeRequest, readRequests, verifyAndCloseRequests, } from "../doctor/requests.js";
6
+ import { measuredScope, rememberScope } from "../measured-scope.js";
6
7
  import { body, paint, section, snippet } from "../output.js";
7
8
  import { repoStateOf } from "../repo-state.js";
8
- import { resolveReadParts, takeCensus } from "./import.js";
9
+ import { resolveReadParts, siblingProjects, takeCensus } from "./import.js";
9
10
  /**
10
11
  * What each decision means ON THIS MACHINE - the card decides, the sync
11
12
  * enacts. The web never touches the local file; this line is printed right
@@ -191,13 +192,50 @@ export async function remeasure(args) {
191
192
  console.log(paint.blue(snippet(["npx synthesisui@latest connect"])));
192
193
  console.log(body(paint.faint("That rewires the hook and the tools to the new version - the editor picks them up on the next session.")));
193
194
  }
195
+ /**
196
+ * O ESCOPO SAI DO DISCO ANTES DE SAIR DO SERVIDOR.
197
+ *
198
+ * Ele mora nos dois lugares, e o do disco é o que não pode se perder: o censo local É a foto da
199
+ * última medição e carrega o `scope` que a mediu. O servidor guarda uma cópia, e uma cópia pode
200
+ * ser sobrescrita - em 06/08 foi, e o sistema ficou sem escopo nenhum lá.
201
+ *
202
+ * O preço daquilo não foi um número errado, foi um LOOP: sem escopo, esta linha media o repo
203
+ * inteiro, mandava 338 componentes de app para um sistema de 36, e o `sync` seguinte fazia de
204
+ * novo. Perguntar ao servidor uma coisa que está do lado de cá é o que fechava o ciclo.
205
+ *
206
+ * O servidor continua valendo como segunda fonte: um clone fresco tem o `.lock` e não tem censo.
207
+ */
208
+ const local = await measuredScope(root);
209
+ let scope = local.system ?? stored.scope ?? null;
210
+ let usage = local.usage.length > 0 ? local.usage : (stored.usage ?? []);
211
+ /**
212
+ * E QUANDO NINGUÉM SABE, ESTE COMANDO PERGUNTA - uma vez, e grava a resposta.
213
+ *
214
+ * Um sistema que perdeu o escopo só tinha uma volta: `import --scope`. Isso é uma SEXTA palavra
215
+ * no vocabulário de quem usa isto (`connect`, `login`, `doctor`, `sync`, `upgrade`, e a skill), e
216
+ * uma mensagem que ensina uma palavra nova transfere para a pessoa um problema que era nosso.
217
+ *
218
+ * Então o comando que ela já roda absorve o caso: os candidatos saem do próprio repo, a resposta
219
+ * vai para o `.lock`, e a pergunta não volta.
220
+ */
221
+ if (!scope) {
222
+ const asked = await askForScope(root);
223
+ if (asked) {
224
+ scope = asked.scope;
225
+ if (asked.usage.length > 0)
226
+ usage = asked.usage;
227
+ await rememberScope(root, slug, scope, usage);
228
+ }
229
+ }
194
230
  console.log(section("Measuring your repository again"));
195
- console.log(body(paint.faint(`${stored.scope ? `scope ${stored.scope}` : "the whole repo"} · the reading you already authored travels unchanged`)));
196
- const census = await takeCensus(stored.scope ? join(root, stored.scope) : root, {
197
- ...(stored.scope ? { scopeLabel: stored.scope } : {}),
231
+ console.log(body(paint.faint(`${scope ? `scope ${scope}` : "the whole repo"}${local.system && local.system !== stored.scope
232
+ ? " · read from your own census, which is where the last measurement recorded it"
233
+ : ""} · the reading you already authored travels unchanged`)));
234
+ const census = await takeCensus(scope ? join(root, scope) : root, {
235
+ ...(scope ? { scopeLabel: scope } : {}),
198
236
  ...(args.cli ? { cli: args.cli } : {}),
199
237
  /** O `--usage` é EVIDÊNCIA e leva rótulo: sem ele o censo não sabe de que app o número veio. */
200
- usage: (stored.usage ?? []).map((u) => ({
238
+ usage: usage.map((u) => ({
201
239
  path: join(root, u),
202
240
  label: u,
203
241
  })),
@@ -316,3 +354,44 @@ export async function installedSlug(root) {
316
354
  }
317
355
  return null;
318
356
  }
357
+ /**
358
+ * DE ONDE ESTE SISTEMA FOI MEDIDO - perguntado uma vez, com os candidatos do próprio repo.
359
+ *
360
+ * Os nomes não são digitados: `siblingProjects` já separa o que é biblioteca compartilhada do que é
361
+ * app, que é exatamente a distinção entre o SISTEMA e quem o CONSOME. Um monorepo responde em uma
362
+ * tecla; um repo de app único nem chega aqui, porque a raiz é a resposta óbvia e ela não precisa de
363
+ * pergunta.
364
+ *
365
+ * Sem terminal interativo devolve `null` e a medição segue como antes - um CI não pode travar
366
+ * esperando alguém, e medir a raiz num CI é o comportamento que ele sempre teve.
367
+ */
368
+ async function askForScope(root) {
369
+ const { apps, shared } = await siblingProjects(root);
370
+ if (shared.length === 0)
371
+ return null;
372
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
373
+ return null;
374
+ console.log(section("Where does this system come from?"));
375
+ console.log(body("Nothing here records it, so a re-measure would read this whole repo - which produces the average of every app you have, and that is a diagnosis rather than a system."));
376
+ shared.forEach((name, i) => {
377
+ console.log(body(` ${i + 1}. ${name}`));
378
+ });
379
+ console.log(body(` ${shared.length + 1}. the whole repo`));
380
+ if (apps.length > 0)
381
+ console.log(body(paint.faint(`${apps.length} app${apps.length === 1 ? "" : "s"} travel as evidence either way: ${apps.slice(0, 3).join(", ")}${apps.length > 3 ? ", …" : ""}`)));
382
+ const { createInterface } = await import("node:readline/promises");
383
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
384
+ try {
385
+ const answer = (await rl.question(" which one? [1]: ")).trim();
386
+ const pick = answer === "" ? 1 : Number(answer);
387
+ if (!Number.isInteger(pick) || pick < 1 || pick > shared.length)
388
+ return null;
389
+ return { scope: shared[pick - 1], usage: apps };
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ finally {
395
+ rl.close();
396
+ }
397
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { absorb } from "./commands/absorb.js";
4
4
  import { add } from "./commands/add.js";
5
5
  import { adopt } from "./commands/adopt.js";
6
6
  import { advise } from "./commands/advise.js";
7
+ import { align } from "./commands/align.js";
7
8
  import { ci } from "./commands/ci.js";
8
9
  import { clean } from "./commands/clean.js";
9
10
  import { component } from "./commands/component.js";
@@ -547,6 +548,13 @@ async function main() {
547
548
  yes: args.includes("--yes"),
548
549
  });
549
550
  break;
551
+ /**
552
+ * O que o hook `SessionStart` roda. Silencioso quando está alinhado, e nunca falha - ver
553
+ * `align.ts` para os nove estados e por que sete deles não tinham verificação nenhuma.
554
+ */
555
+ case "align":
556
+ await align({ dir, cli: CLI_VERSION });
557
+ break;
550
558
  case "request": {
551
559
  // `synthesisui request` lists; `request component --name x --for "..."`
552
560
  // files; `request --done <id>` closes. The agent's front door is the MCP
@@ -1,6 +1,51 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  const EMPTY = { system: null, usage: [], from: "none" };
4
+ /**
5
+ * O ESCOPO NO PONTEIRO DO SISTEMA, que é onde ele não se perde.
6
+ *
7
+ * O censo é grande (0,5 MB), é regravado a cada medição e ninguém versiona um arquivo assim sem
8
+ * pagar conflito. O `.lock` tem cinco campos e é o arquivo desta pasta que um time COMMITA - então é
9
+ * ele que faz um clone fresco saber de onde este sistema foi medido, sem uma ida à rede e sem
10
+ * depender de uma cópia no servidor que já foi sobrescrita uma vez (06/08).
11
+ *
12
+ * Um lock sem `scope` é o caso de todo sistema instalado antes disto: cai no censo, como antes.
13
+ */
14
+ async function scopeInLock(root) {
15
+ const dsDir = join(root, "_synthesisui", "ds");
16
+ const names = await readdir(dsDir, { withFileTypes: true }).catch(() => []);
17
+ for (const entry of names) {
18
+ if (!entry.isDirectory())
19
+ continue;
20
+ const raw = await readFile(join(dsDir, entry.name, ".lock"), "utf8").catch(() => null);
21
+ if (!raw)
22
+ continue;
23
+ let lock;
24
+ try {
25
+ lock = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ const scope = typeof lock.scope === "string" && lock.scope.trim()
31
+ ? lock.scope.trim()
32
+ : null;
33
+ const usage = Array.isArray(lock.usage)
34
+ ? lock.usage.filter((u) => typeof u === "string" && !!u.trim())
35
+ : [];
36
+ if (!scope && usage.length === 0)
37
+ continue;
38
+ const system = scope && (await present(root, scope)) ? scope : null;
39
+ const kept = [];
40
+ for (const u of usage)
41
+ if (await present(root, u))
42
+ kept.push(u);
43
+ if (!system && kept.length === 0)
44
+ continue;
45
+ return { system, usage: kept, from: "lock" };
46
+ }
47
+ return null;
48
+ }
4
49
  /** Existe como diretório? Um escopo gravado e depois renomeado não pode derrubar o comando. */
5
50
  async function present(root, rel) {
6
51
  return stat(resolve(root, rel)).then((s) => s.isDirectory(), () => false);
@@ -13,6 +58,19 @@ async function present(root, rel) {
13
58
  * comportamento antigo (a raiz).
14
59
  */
15
60
  export async function measuredScope(root) {
61
+ const fromLock = await scopeInLock(root);
62
+ if (fromLock)
63
+ return fromLock;
64
+ return censusScope(root);
65
+ }
66
+ /**
67
+ * O que o CENSO diz sobre onde, sem olhar o lock.
68
+ *
69
+ * Quem ESCREVE o lock lê por aqui, e a ordem é o contrário da leitura de propósito: o censo é a
70
+ * medição fresca que acabou de rodar, e o lock é a cópia da anterior. Ler o lock para escrever o
71
+ * lock congelaria o primeiro escopo para sempre, e um `--scope` novo nunca chegaria lá.
72
+ */
73
+ export async function censusScope(root) {
16
74
  const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => null);
17
75
  if (!raw)
18
76
  return EMPTY;
@@ -61,3 +119,31 @@ export function describeScope(m) {
61
119
  ];
62
120
  return `read from your census: ${parts.join(" · ")}`;
63
121
  }
122
+ /**
123
+ * GRAVA ONDE ESTE SISTEMA FOI MEDIDO, no ponteiro dele.
124
+ *
125
+ * Existe para uma resposta ser dada UMA vez. O escopo se perdeu do servidor em 06/08 e o único
126
+ * caminho de volta era um `import --scope`, que é um comando fora do vocabulário de quem usa isto -
127
+ * `connect`, `login`, `doctor`, `sync`, `upgrade` e a skill. Uma mensagem que ensina uma sexta
128
+ * palavra é pior que mensagem nenhuma: ela transfere para a pessoa um problema que era nosso.
129
+ *
130
+ * Silencioso quando não há lock: não é erro, é um repo sem sistema instalado.
131
+ */
132
+ export async function rememberScope(root, slug, scope, usage) {
133
+ const path = join(root, "_synthesisui", "ds", slug, ".lock");
134
+ const raw = await readFile(path, "utf8").catch(() => null);
135
+ if (!raw)
136
+ return;
137
+ let lock;
138
+ try {
139
+ lock = JSON.parse(raw);
140
+ }
141
+ catch {
142
+ return;
143
+ }
144
+ await writeFile(path, `${JSON.stringify({
145
+ ...lock,
146
+ ...(scope ? { scope } : {}),
147
+ ...(usage.length > 0 ? { usage } : {}),
148
+ }, null, 2)}\n`, "utf8");
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.159",
3
+ "version": "0.16.161",
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": {