synthesisui 0.16.450 → 0.16.455

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.
@@ -4,30 +4,20 @@ import { dirname, join } from "node:path";
4
4
  import { hasCodexBlock, pinnedInCodex, withCodexBlock } from "./codex-mcp.js";
5
5
  import { wrapperSource } from "./statusline-wrapper.js";
6
6
  /**
7
- * QUANDO A CHECAGEM RODA - e por que `Bash` entrou em 12/09.
7
+ * QUANDO A CHECAGEM RODA - no FIM DO TURNO do agente, desde 23/09.
8
8
  *
9
- * ═══ O QUE O CLIENTE VIA ═══
10
- *
11
- * O filtro era `Write|Edit|MultiEdit`, e com isso a garantia inteira dependia de qual ferramenta o
12
- * agente escolhesse. Medido no `codelevel` em três sessões: toda edição em lote passou por comando
13
- * de shell e a checagem produziu **0 linhas**; na única edição feita pela ferramenta de edição ela
14
- * falou na hora, com a regra do sistema nomeada. O produto promete atuar quando ele escreve
15
- * frontend - não quando o agente segura a caneta certa.
16
- *
17
- * Um comando de shell não diz quais arquivos tocou, então quem responde é a árvore de trabalho -
18
- * ver `changed-files.ts`, onde também está o custo medido.
9
+ * Ela rodava depois de cada escrita (`PostToolUse`, com o filtro `Write|Edit|MultiEdit|Bash` desde
10
+ * 12/09, quando se mediu que toda edição em lote do `codelevel` passava por shell). A escolha dele
11
+ * em 23/09, olhando o ledger do mesmo repositório - 850 execuções, 3 checagens em 303 com achado:
12
+ * *"no fim de cada turno do agente, sobre o diff"*. O `Stop` não tem filtro: ele roda uma vez por
13
+ * turno, e quem acha o que o turno escreveu é a árvore de trabalho (`changed-files.ts`), seja qual
14
+ * for a ferramenta que o agente usou.
19
15
  */
20
- const HOOK_MATCHER = "Write|Edit|MultiEdit|Bash";
21
- /**
22
- * O FILTRO QUE ESTE PRODUTO ESCREVIA ANTES DE 12/09 - e a única string que ele se autoriza a
23
- * alargar.
24
- *
25
- * Quem conectou antes disso tem no arquivo dele um filtro que NÓS escrevemos, e deixá-lo como está
26
- * seria entregar a correção só para quem instala do zero. Alargar um filtro que uma PESSOA
27
- * escreveu é outra coisa, e continua proibido: a comparação é por igualdade exata com a nossa
28
- * string antiga, nunca por parecer com ela.
29
- */
30
- const HOOK_MATCHER_BEFORE_SHELL = "Write|Edit|MultiEdit";
16
+ const HOOK_EVENT = "Stop";
17
+ /** O evento de ANTES - onde uma instalação anterior a 23/09 tem o nosso hook, e de onde ele sai. */
18
+ const HOOK_EVENT_BEFORE = "PostToolUse";
19
+ /** O NOSSO hook de checagem, e só ele: o `align` da abertura de sessão também diz "synthesisui". */
20
+ const isOurHook = (command) => /\bsynthesisui(@\S+)? hook\b/.test(command ?? "");
31
21
  /**
32
22
  * Exportado porque a REGRA é uma só: a pasta de uma ferramenta é a evidência de que ela é usada, e
33
23
  * a mesma evidência decide o MCP daqui e a casa do bloco em `claude-md.ts`. Duas cópias deste
@@ -111,87 +101,98 @@ export async function alignCommand(root, version) {
111
101
  export async function pinnedHookVersion(root) {
112
102
  const settings = await readJson(join(root, ".claude", "settings.json")).catch(() => ({}));
113
103
  const hooks = (settings.hooks ?? {});
114
- const entries = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
115
- for (const entry of entries)
116
- for (const h of entry.hooks ?? []) {
117
- const command = h.command ?? "";
118
- if (/--no-install synthesisui hook/.test(command))
119
- return null;
120
- const m = /synthesisui@(\d+\.\d+\.\d+)/.exec(command);
121
- if (m)
122
- return m[1];
123
- }
104
+ /** O `Stop` primeiro: um projeto no meio da mudança lê a versão de onde o hook roda agora. */
105
+ for (const event of [HOOK_EVENT, HOOK_EVENT_BEFORE]) {
106
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
107
+ for (const entry of entries)
108
+ for (const h of entry.hooks ?? []) {
109
+ const command = h.command ?? "";
110
+ if (!isOurHook(command))
111
+ continue;
112
+ if (/--no-install synthesisui hook/.test(command))
113
+ return null;
114
+ const m = /synthesisui@(\d+\.\d+\.\d+)/.exec(command);
115
+ if (m)
116
+ return m[1];
117
+ }
118
+ }
124
119
  return null;
125
120
  }
126
121
  async function wireHook(root, command) {
127
122
  const dir = join(root, ".claude");
128
123
  const path = join(dir, "settings.json");
129
124
  const settings = await readJson(path);
130
- const hooks = (settings.hooks ?? {});
131
- const post = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
125
+ const hooks = { ...(settings.hooks ?? {}) };
132
126
  /**
133
- * O CASO QUE ESTA FUNÇÃO NÃO COBRIA, e ele tornava o `connect` uma promessa falsa.
127
+ * O HOOK DE ANTES SAI, e só ele - E02 da jornada "a checagem no fim do turno".
134
128
  *
135
- * Ela achava QUALQUER hook contendo "synthesisui" e devolvia "already there" sem olhar a versão -
136
- * então um hook pinado nunca subia. Medido em 06/08: o dono rodou `npx synthesisui connect` com o
137
- * 0.16.157 e a saída disse `· .claude/settings.json already had it / npx synthesisui@0.16.153 hook`.
138
- * O pin era permanente até alguém editar o arquivo à mão, e a mensagem que a gente acabou de escrever
139
- * - "rode connect, depois reinicie" - mandava rodar um comando que não fazia a coisa.
140
- *
141
- * Agora o comando é REESCRITO no lugar quando ele é nosso e difere. O que a idempotência original
142
- * protegia continua protegido: o matcher fica como estiver, os hooks vizinhos ficam, e uma segunda
143
- * entrada nunca é criada - duas entradas rodariam o verificador duas vezes por escrita.
129
+ * Um projeto conectado antes de 23/09 tem o nosso hook no `PostToolUse`. Deixá-lo lá rodaria a
130
+ * checagem duas vezes - por escrita e no fim do turno -, que é o ruído que a mudança existe para
131
+ * tirar. Sai o NOSSO comando, e nada além: um hook dele na mesma entrada fica, com o filtro que
132
+ * ele escreveu, e a entrada só some quando fica vazia.
144
133
  */
145
- const owner = post.find((e) => (e.hooks ?? []).some((h) => (h.command ?? "").includes("synthesisui")));
146
- const mine = (owner?.hooks ?? []).find((h) => (h.command ?? "").includes("synthesisui"));
134
+ let was;
135
+ const before = Array.isArray(hooks[HOOK_EVENT_BEFORE])
136
+ ? hooks[HOOK_EVENT_BEFORE]
137
+ : [];
138
+ const kept = [];
139
+ for (const entry of before) {
140
+ const mine = (entry.hooks ?? []).filter((h) => isOurHook(h.command));
141
+ if (mine.length === 0) {
142
+ kept.push(entry);
143
+ continue;
144
+ }
145
+ was = was ?? mine[0]?.command;
146
+ const theirs = (entry.hooks ?? []).filter((h) => !isOurHook(h.command));
147
+ if (theirs.length > 0)
148
+ kept.push({ ...entry, hooks: theirs });
149
+ }
150
+ if (was !== undefined) {
151
+ if (kept.length > 0)
152
+ hooks[HOOK_EVENT_BEFORE] = kept;
153
+ else
154
+ delete hooks[HOOK_EVENT_BEFORE];
155
+ }
156
+ const stop = Array.isArray(hooks[HOOK_EVENT]) ? [...hooks[HOOK_EVENT]] : [];
157
+ const owner = stop.find((e) => (e.hooks ?? []).some((h) => isOurHook(h.command)));
158
+ const mine = (owner?.hooks ?? []).find((h) => isOurHook(h.command));
159
+ const save = async () => {
160
+ await mkdir(dir, { recursive: true });
161
+ await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, [HOOK_EVENT]: stop } }, null, 2)}\n`, "utf8");
162
+ };
147
163
  if (owner && mine) {
148
- const was = mine.command ?? "";
149
- /**
150
- * O FILTRO NOSSO ANTIGO SOBE JUNTO - ver `HOOK_MATCHER_BEFORE_SHELL`.
151
- *
152
- * Sem isto, a checagem passar a valer para edição por shell alcançaria só quem instalasse do
153
- * zero: quem já tinha conectado ficaria com o filtro estreito para sempre, e nada no produto
154
- * diria isso a ele.
155
- *
156
- * **E SÓ QUANDO A ENTRADA É NOSSA INTEIRA** - achado da revisão de contrato no fecho, e a
157
- * fronteira estava larga demais. `matcher` vale para TODOS os comandos da entrada: se ele
158
- * acrescentou um hook dele ali ao lado - um formatador, um linter -, alargar o filtro passaria
159
- * a disparar o comando DELE depois de todo comando de shell, sem ele ter pedido. É a mesma lei
160
- * que impede escrever arquivo de um agente que ele não escolheu.
161
- */
162
- const onlyOurs = (owner.hooks ?? []).every((h) => (h.command ?? "").includes("synthesisui"));
163
- const widen = owner.matcher === HOOK_MATCHER_BEFORE_SHELL && onlyOurs;
164
- if (widen)
165
- owner.matcher = HOOK_MATCHER;
164
+ const there = mine.command ?? "";
166
165
  /**
167
166
  * SÓ UM PIN NOSSO É SUBSTITUÍDO, e a fronteira é estreita de propósito.
168
167
  *
169
168
  * Um comando SEM versão (`npx synthesisui hook`) ou com instalação local é escolha deliberada de
170
- * alguém: reescrevê-lo seria pinar a decisão da pessoa, e um spec já protegia isso - o caso do
171
- * matcher alargado à mão. O que sobe é `synthesisui@<semver>` para outro semver, que é o único caso
172
- * em que "already there" mentia (dono, 06/08: `connect` do 0.16.157 imprimindo
173
- * `npx synthesisui@0.16.153 hook` como "already had it").
169
+ * alguém: reescrevê-lo seria pinar a decisão da pessoa. O que sobe é `synthesisui@<semver>` para
170
+ * outro semver, que é o único caso em que "already there" mentia (dono, 06/08: `connect` do
171
+ * 0.16.157 imprimindo `npx synthesisui@0.16.153 hook` como "already had it").
174
172
  */
175
- const pinnedAt = /synthesisui@(\d+\.\d+\.\d+)/.exec(was)?.[1];
173
+ const pinnedAt = /synthesisui@(\d+\.\d+\.\d+)/.exec(there)?.[1];
176
174
  const proposedAt = /synthesisui@(\d+\.\d+\.\d+)/.exec(command)?.[1];
177
175
  const samePin = !pinnedAt || !proposedAt || pinnedAt === proposedAt;
178
- if (samePin && !widen)
179
- return { status: "already there", command: was || command };
180
176
  if (!samePin)
181
177
  mine.command = command;
182
- await mkdir(dir, { recursive: true });
183
- await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, PostToolUse: post } }, null, 2)}\n`, "utf8");
184
- return samePin
185
- ? { status: "updated", command: was || command }
186
- : { status: "updated", command, was };
178
+ if (samePin && was === undefined)
179
+ return { status: "already there", command: there || command };
180
+ await save();
181
+ if (was !== undefined)
182
+ return { status: "moved", command: mine.command ?? command, was };
183
+ return { status: "updated", command, was: there };
187
184
  }
188
- post.push({
189
- matcher: HOOK_MATCHER,
190
- hooks: [{ type: "command", command }],
191
- });
192
- await mkdir(dir, { recursive: true });
193
- await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, PostToolUse: post } }, null, 2)}\n`, "utf8");
194
- return { status: "added", command };
185
+ /**
186
+ * O COMANDO QUE VAI PARA O FIM DO TURNO. Quem vem do hook de antes leva a escolha que fez lá - um
187
+ * comando sem versão, ou a instalação local, continua sendo dele. Um pin nosso sobe para esta
188
+ * versão: o hook pinado antes de 0.16.452 não conhece o `Stop`, e ali ele ficaria mudo.
189
+ */
190
+ const carried = was !== undefined && !/synthesisui@\d+\.\d+\.\d+/.test(was) ? was : command;
191
+ stop.push({ hooks: [{ type: "command", command: carried }] });
192
+ await save();
193
+ return was !== undefined
194
+ ? { status: "moved", command: carried, was }
195
+ : { status: "added", command };
195
196
  }
196
197
  /**
197
198
  * O MESMO SERVIDOR MCP, NOS CAMINHOS QUE CADA AGENTE LÊ.
@@ -483,9 +484,8 @@ async function wireSessionStart(root, command) {
483
484
  */
484
485
  export async function hasHook(root) {
485
486
  const settings = await readJson(join(root, ".claude", "settings.json")).catch(() => ({}));
486
- const post = (settings.hooks ?? {})
487
- .PostToolUse;
488
- if (!Array.isArray(post))
489
- return false;
490
- return post.some((e) => (e.hooks ?? []).some((h) => (h.command ?? "").includes("synthesisui")));
487
+ const hooks = (settings.hooks ?? {});
488
+ /** Os dois eventos: um projeto que ainda não rodou o `connect` novo continua com a garantia. */
489
+ return [HOOK_EVENT, HOOK_EVENT_BEFORE].some((event) => Array.isArray(hooks[event]) &&
490
+ hooks[event].some((e) => (e.hooks ?? []).some((h) => isOurHook(h.command))));
491
491
  }
@@ -0,0 +1,81 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /**
5
+ * O SYNC NO FIM DO TURNO, COM O SIM DE QUEM INSTALA - E03 da jornada "a checagem no fim do turno".
6
+ *
7
+ * A escolha dele em 23/09: *"no fim de cada turno do agente, sobre o diff, fazendo o sync no mesmo
8
+ * passo"*. A pergunta que a abriu: *"isso agora vai ser automático quando rodar o Synth lá?"*. No
9
+ * CodeLevel, entre 22/09 e 23/09, 984 linhas do ledger ficaram paradas porque ninguém rodou `sync`,
10
+ * e o painel dizia que o agente tinha trabalhado menos do que trabalhou.
11
+ *
12
+ * ═══ A RESPOSTA É DESTA MÁQUINA, e não do repositório ═══
13
+ *
14
+ * `_synthesisui/config.json` é versionado: um sim gravado lá faria o colega que clona o repositório
15
+ * enviar sem ter sido perguntado. É o mesmo motivo do `.mode` (`mode.ts`): a escolha de uma pessoa
16
+ * nunca decide por outra. O arquivo mora ao lado, na lista do que o `.gitignore` da pasta cobre.
17
+ *
18
+ * ═══ NUNCA PERGUNTADO É NÃO ═══
19
+ *
20
+ * Sem o arquivo, nada é enviado - a promessa que o produto fazia até aqui era "nada sai da máquina
21
+ * sem você rodar um comando", e ela continua valendo para quem não disse sim.
22
+ */
23
+ export const autoSyncPath = (root) => join(root, "_synthesisui", ".auto-sync");
24
+ export async function readAutoSync(root) {
25
+ const raw = await readFile(autoSyncPath(root), "utf8").catch(() => null);
26
+ if (raw == null)
27
+ return null;
28
+ const value = raw.trim().toLowerCase();
29
+ return value === "yes" ? true : value === "no" ? false : null;
30
+ }
31
+ export async function writeAutoSync(root, yes) {
32
+ await writeFile(autoSyncPath(root), yes ? "yes\n" : "no\n", "utf8");
33
+ }
34
+ /**
35
+ * QUANTO TEMPO UM SYNC DISPARADO SEGURA O PRÓXIMO.
36
+ *
37
+ * Um agente encerra turnos em sequência, às vezes a cada poucos segundos, e cada `sync` relê o
38
+ * repositório inteiro e fala com o servidor. Dois ao mesmo tempo escreveriam o mesmo cursor
39
+ * (`.ledger-sent`) e poderiam mandar as mesmas linhas duas vezes. Dois minutos cobrem uma medição
40
+ * com folga; o que ficar para trás vai no sync seguinte, porque o ledger só anda para frente.
41
+ *
42
+ * A LACUNA DECLARADA: o último turno de uma sessão, se cair dentro da janela, só sobe no próximo
43
+ * sync - numa sessão seguinte, ou quando alguém rodar o comando.
44
+ */
45
+ export const SYNC_GAP_MS = 2 * 60_000;
46
+ const lockPath = (root) => join(root, "_synthesisui", ".sync-started");
47
+ /**
48
+ * Dispara o `sync` DESTACADO e volta na hora - o turno do agente não espera a rede.
49
+ *
50
+ * O binário é o MESMO que está rodando o hook (`process.argv[1]`): o `npx synthesisui@<v> hook`
51
+ * pinado resolve para um caminho, e o sync sai daquele caminho, na mesma versão. Um `npx` novo
52
+ * resolveria outra vez no registro, que é a espera que a instalação local existe para evitar.
53
+ *
54
+ * `stdio: "ignore"` também é a razão de ele nunca travar: sem terminal, toda pergunta do `sync`
55
+ * (`askToOverwrite`, `askForScope`) responde não sozinha.
56
+ */
57
+ export async function startSync(root) {
58
+ const last = await stat(lockPath(root)).then((s) => s.mtimeMs, () => 0);
59
+ if (Date.now() - last < SYNC_GAP_MS)
60
+ return false;
61
+ await writeFile(lockPath(root), "", "utf8");
62
+ const entry = process.argv[1];
63
+ if (!entry) {
64
+ await rm(lockPath(root), { force: true });
65
+ return false;
66
+ }
67
+ try {
68
+ const child = spawn(process.execPath, [entry, "sync"], {
69
+ cwd: root,
70
+ detached: true,
71
+ stdio: "ignore",
72
+ env: process.env,
73
+ });
74
+ child.on?.("error", () => { });
75
+ child.unref();
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
package/dist/claude-md.js CHANGED
@@ -262,8 +262,9 @@ Better than remembering: \`npx synthesisui@latest connect\` installs the check a
262
262
  an editor hook, so it runs on its own and this paragraph stops being your job.${VERIFY}`;
263
263
  const SELF_CHECK_HOOKED = `
264
264
 
265
- **The check runs by itself.** A hook reports on every file you write, naming the
266
- token this project already has for anything hardcoded. You do not need to run
265
+ **The check runs by itself.** At the end of every turn, a hook reports on the
266
+ files the turn changed, naming the token this project already has for anything
267
+ hardcoded. You do not need to run
267
268
  \`doctor\` by hand, and you should not wait for a final pass.
268
269
 
269
270
  When it names something, fix it in that file before moving to the next one -
@@ -59,6 +59,18 @@ const IGNORED = [
59
59
  * outra"*. Esta lista reconcilia, então quem já está conectado também recebe a linha.
60
60
  */
61
61
  ".mode",
62
+ /**
63
+ * O SIM DELA PARA O ENVIO NO FIM DO TURNO - ver `auto-sync.ts`. Pelo mesmo motivo do `.mode`:
64
+ * commitado, o sim de uma pessoa enviaria da máquina de todo o time.
65
+ */
66
+ ".auto-sync",
67
+ /** A trava que impede dois syncs ao mesmo tempo - descreve ESTE clone. */
68
+ ".sync-started",
69
+ /**
70
+ * O RELÓGIO DO FIM DO TURNO (`hook.ts`, E01 de 23/09). Ele nasceu fora desta lista, e o primeiro
71
+ * turno de quem instalasse faria um arquivo nosso aparecer no `git status` dele.
72
+ */
73
+ ".turn-clock",
62
74
  ];
63
75
  const IGNORE_HEADER = "# Managed by synthesisui. The identity and the CSS are committed so a fresh\n" +
64
76
  "# clone is governed; the measurement and the local record are not, because\n" +
@@ -333,7 +333,7 @@ opts = {}) {
333
333
  */
334
334
  if (folderBehind && hookBehind && folderBehind === hookBehind)
335
335
  out.push({
336
- says: `you are running CLI ${cli} and two things here are still on ${folderBehind}: the files under _synthesisui/ds/${lock.slug}, and the check that runs after every write - so your agent's edits are read by the older one.`,
336
+ says: `you are running CLI ${cli} and two things here are still on ${folderBehind}: the files under _synthesisui/ds/${lock.slug}, and the check that runs at the end of each agent turn - so your agent's work is read by the older one.`,
337
337
  run: "npx synthesisui upgrade",
338
338
  });
339
339
  else {
@@ -344,7 +344,7 @@ opts = {}) {
344
344
  });
345
345
  if (hookBehind)
346
346
  out.push({
347
- says: `the check that runs after every write is pinned to CLI ${hookBehind} and you are running ${cli} - your agent's edits are being checked by an older reader than the one measuring this repo.`,
347
+ says: `the check that runs at the end of each agent turn is pinned to CLI ${hookBehind} and you are running ${cli} - your agent's work is being checked by an older reader than the one measuring this repo.`,
348
348
  run: "npx synthesisui upgrade",
349
349
  });
350
350
  }
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { codexPinBefore, exists, wireAgent } from "../agent-wiring.js";
4
4
  import { AGENTS, chooseAgents, filesOf, rememberChoice, } from "../agents-chosen.js";
5
+ import { readAutoSync, writeAutoSync } from "../auto-sync.js";
5
6
  import { blockHomes, syncClaudeMd } from "../claude-md.js";
6
7
  import { resolveRegistry } from "../config.js";
7
8
  import { guaranteeIfNew } from "../guarantee.js";
@@ -25,7 +26,7 @@ import { installedSlugs } from "./sync.js";
25
26
  * of them and got, instead, the CLAUDE.md instruction we had already measured
26
27
  * being ignored.
27
28
  *
28
- * hook guarantees runs after every write, whether the agent likes it or not
29
+ * hook guarantees runs at the end of every agent turn, whether the agent likes it or not
29
30
  * mcp audits lets the agent ask the system instead of guessing
30
31
  * md informs and now stops repeating what the hook already ensures
31
32
  *
@@ -451,18 +452,50 @@ export async function connect(opts) {
451
452
  * de duas frases que a etapa 11 fechou no `template`, aparecendo na tela que a 12 acabou de
452
453
  * escrever - e `wired.hook` diz `skipped`, então a informação para suprimir já estava aqui.
453
454
  */
455
+ /**
456
+ * O CODEX NÃO TEM FIM DE TURNO QUE SE SEGURE - E02 da jornada "a checagem no fim do turno". A
457
+ * checagem mora no `Stop` do Claude Code; num repositório que escolheu só o Codex ela não existe,
458
+ * e a tela diz isso em vez de deixar ele supor que o agente dele está coberto.
459
+ */
460
+ if (want.hook && wired.hook === "skipped" && chosen.agents.includes("codex"))
461
+ /** `true`: é aviso, não estado - uma linha "já estava lá" some no resumo, e esta não pode. */
462
+ row(true, say("· Codex has no end-of-turn hook we can hold - run `npx synthesisui doctor` to check what it wrote"));
463
+ /**
464
+ * O ENVIO NO FIM DO TURNO, PERGUNTADO UMA VEZ - E03 da jornada "a checagem no fim do turno".
465
+ *
466
+ * A pergunta existe porque a promessa mudou: até aqui, nada saía da máquina dele sem ele rodar um
467
+ * comando. Quem diz sim passa a ter o painel em dia sozinho; quem diz não - ou quem nunca foi
468
+ * perguntado, como um CI - continua exatamente como antes. A resposta mora em `.auto-sync`, fora do
469
+ * git (ver `auto-sync.ts`), e com ela gravada a pergunta não volta.
470
+ */
471
+ if (want.hook && wired.hook !== "skipped") {
472
+ const before = await readAutoSync(root).catch(() => null);
473
+ const now = before ?? (await askToSendAtTurnEnd());
474
+ if (before === null && now !== null)
475
+ await writeAutoSync(root, now);
476
+ row(before === null, now === true
477
+ ? say("✓ what the check records is sent to your dashboard at the end of each turn")
478
+ : say("· what the check records stays on this machine - run `npx synthesisui sync` to send it"));
479
+ }
454
480
  if (want.hook && wired.hook !== "skipped") {
455
481
  row(wired.hook !== "already there", (() => {
456
482
  return wired.hook === "added"
457
- ? say("✓ .claude/settings.json the check now runs after every write")
458
- : wired.hook === "updated"
483
+ ? say("✓ .claude/settings.json the check now runs at the end of each agent turn")
484
+ : wired.hook === "moved"
459
485
  ? /**
460
- * A LINHA QUE FALTAVA, e a ausência dela fazia o comando mentir: rodando o 0.16.157, a
461
- * saída dizia "already had it" e imprimia `npx synthesisui@0.16.153 hook` embaixo
462
- * (dono, 06/08). Quem lê "already had it" fecha o terminal.
486
+ * A CHECAGEM MUDOU DE LUGAR, dita como mudança - E02 da jornada "a checagem no fim do
487
+ * turno". Quem tinha o hook por escrita precisa saber que ele não roda mais ali, ou vai
488
+ * estranhar o silêncio no meio do turno.
463
489
  */
464
- `✓ .claude/settings.json the check moved from ${wired.was?.match(/synthesisui@(\d+\.\d+\.\d+)/)?.[1] ?? "an older version"} to this one`
465
- : say("· .claude/settings.json already had it");
490
+ say("✓ .claude/settings.json the check moved from every write to the end of each agent turn")
491
+ : wired.hook === "updated"
492
+ ? /**
493
+ * A LINHA QUE FALTAVA, e a ausência dela fazia o comando mentir: rodando o 0.16.157, a
494
+ * saída dizia "already had it" e imprimia `npx synthesisui@0.16.153 hook` embaixo
495
+ * (dono, 06/08). Quem lê "already had it" fecha o terminal.
496
+ */
497
+ `✓ .claude/settings.json the check moved from ${wired.was?.match(/synthesisui@(\d+\.\d+\.\d+)/)?.[1] ?? "an older version"} to this one`
498
+ : say("· .claude/settings.json already had it");
466
499
  })(), wired.command);
467
500
  /**
468
501
  * A SEGUNDA COSTURA, dita por nome. As onze ferramentas MCP são PULL e o hook de escrita roda
@@ -677,7 +710,7 @@ export async function connect(opts) {
677
710
  * interrompido precisa saber que só apagar a região para isso.
678
711
  */
679
712
  if (entries.includes(".claude/settings.json"))
680
- console.log(body(paint.faint(say(" the write check inside it keeps running until that entry is gone"))));
713
+ console.log(body(paint.faint(say(" the turn check inside it keeps running until that entry is gone"))));
681
714
  }
682
715
  /**
683
716
  * O CURSOR SAIU DO PRODUTO (A6 da etapa 12), e quem tem os arquivos dele é avisado com os
@@ -760,7 +793,7 @@ export async function connect(opts) {
760
793
  * Um pedido que aparece sempre é um pedido que a pessoa passa a ignorar - e aí ele não serve para
761
794
  * a vez em que era mesmo obrigatório.
762
795
  */
763
- const moved = (s) => s === "added" || s === "updated";
796
+ const moved = (s) => s === "added" || s === "updated" || s === "moved";
764
797
  const needsRestart = moved(wired.hook) ||
765
798
  moved(wired.session) ||
766
799
  (Array.isArray(wired.mcp) && wired.mcp.some((m) => moved(m.status)));
@@ -778,7 +811,7 @@ export async function connect(opts) {
778
811
  ...(bothChecks
779
812
  ? [say("the checks")]
780
813
  : moved(wired.hook)
781
- ? [say("the write check")]
814
+ ? [say("the turn check")]
782
815
  : moved(wired.session)
783
816
  ? [say("the session check")]
784
817
  : []),
@@ -825,8 +858,30 @@ export async function connect(opts) {
825
858
  * `apps/web/src/lib/cli/commands.spec.ts`.
826
859
  */
827
860
  console.log("");
828
- console.log(bodyWrapped(say("The hook resolves against the npm registry on each edit - that is the wait, not the check. Add synthesisui to your devDependencies and run this again; it switches by itself to local, several times faster."))
861
+ console.log(bodyWrapped(say("The hook resolves against the npm registry at the end of each turn - that is the wait, not the check. Add synthesisui to your devDependencies and run this again; it switches by itself to local, several times faster."))
829
862
  .map(paint.dim)
830
863
  .join("\n"));
831
864
  }
832
865
  }
866
+ /**
867
+ * Pergunta uma vez, e só num terminal de verdade - o mesmo molde do `askToOverwrite` do `sync`.
868
+ *
869
+ * Sem TTY devolve `null`: não há quem responder, e nada é gravado. O padrão do terminal é NÃO
870
+ * (`[y/N]`), porque enviar é a mudança, e uma mudança de promessa pede um sim dito.
871
+ */
872
+ async function askToSendAtTurnEnd() {
873
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
874
+ return null;
875
+ const { createInterface } = await import("node:readline/promises");
876
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
877
+ try {
878
+ const answer = await rl.question(` ${say("send what the check records to your dashboard at the end of each turn?")} [y/N]: `);
879
+ return /^(y(es)?|s(im)?)$/i.test(answer.trim());
880
+ }
881
+ catch {
882
+ return null;
883
+ }
884
+ finally {
885
+ rl.close();
886
+ }
887
+ }
@@ -1,10 +1,12 @@
1
1
  import { readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { readAutoSync, startSync } from "../auto-sync.js";
3
4
  import { changedSince } from "../changed-files.js";
4
- import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
5
+ import { emptyTally, exportedNames, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
5
6
  import { checkContracts } from "../doctor/contract-check.js";
6
7
  import { appendEvent, ledgerPath } from "../doctor/ledger.js";
7
8
  import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
9
+ import { declarados, falaDaPrateleira, naPrateleira } from "../doctor/shelf.js";
8
10
  import { governs, ungovernedIn } from "../governed.js";
9
11
  import { readMode } from "../mode.js";
10
12
  import { namesRemoved, previousText, rulesTouchedBy, } from "../rule-touched.js";
@@ -76,6 +78,16 @@ const clockOf = async (root) => stat(ledgerPath(root)).then((s) => s.mtimeMs, ()
76
78
  * que o produto acabou de ser instalado - e a tela do `connect` já disse que ele está vivo.
77
79
  */
78
80
  const startClock = (root) => writeFile(ledgerPath(root), "", { flag: "a" }).catch(() => { });
81
+ /**
82
+ * O RELÓGIO DO TURNO É PRÓPRIO, e não o do ledger - E01 da jornada "a checagem no fim do turno".
83
+ *
84
+ * O ledger anda a cada comando que escreve nele: um `doctor`, um `align` ou o próprio MCP no MEIO
85
+ * do turno moveriam o relógio, e o fim do turno deixaria de ver tudo o que foi escrito antes
86
+ * deles. Um arquivo só deste relógio anda uma vez por turno, no fim, depois de olhar.
87
+ */
88
+ const TURN_CLOCK = "_synthesisui/.turn-clock";
89
+ const turnClockOf = (root) => stat(join(root, TURN_CLOCK)).then((s) => s.mtimeMs, () => 0);
90
+ const moveTurnClock = (root) => writeFile(join(root, TURN_CLOCK), "", "utf8").catch(() => { });
79
91
  /**
80
92
  * QUANTOS ARQUIVOS UM COMANDO DE SHELL PODE FAZER O PRODUTO RELATAR DE UMA VEZ.
81
93
  *
@@ -122,7 +134,7 @@ async function greet(root, rel) {
122
134
  return [
123
135
  `${rel} - checked, nothing to name. Every value in it has a token.`,
124
136
  "",
125
- "That check now runs on every file you write, by itself. Tell the person once",
137
+ "That check now runs at the end of every turn, by itself. Tell the person once",
126
138
  "that it is live, so they know it installed, and then stop mentioning it - from",
127
139
  "here it only speaks when something needs a name.",
128
140
  ].join("\n");
@@ -361,7 +373,14 @@ async function report(root, filePath, mode) {
361
373
  * parte do relatório que RECUSA, e uma recusa escondida embaixo de uma lista de valores é uma
362
374
  * recusa que ninguém lê.
363
375
  */
376
+ /**
377
+ * A PRATELEIRA VEM ANTES DOS VALORES, e a ordem é o ponto: trocar um hex por
378
+ * um token num componente que não precisava existir é polir o que devia ser
379
+ * apagado. Se a peça já está no sistema, essa é a primeira coisa a saber.
380
+ */
381
+ const prateleira = falaDaPrateleira(rel, naPrateleira(exportedNames(src), declarados(documents)), table.name ?? table.slug ?? "this system");
364
382
  const lines = [
383
+ ...(prateleira.length > 0 ? [...prateleira, ""] : []),
365
384
  ...(composed.length > 0 ? [...composed, ""] : []),
366
385
  ...(broke.length > 0 ? [...broke, ""] : []),
367
386
  `${rel} - checked against ${table.name ?? table.slug}.`,
@@ -417,6 +436,10 @@ export async function hook(opts) {
417
436
  process.stdout.write(`${JSON.stringify(pass())}\n`);
418
437
  return;
419
438
  }
439
+ if (input.hook_event_name === "Stop") {
440
+ await endOfTurn(root, input);
441
+ return;
442
+ }
420
443
  if (input.hook_event_name !== "PostToolUse") {
421
444
  process.stdout.write(`${JSON.stringify(pass())}\n`);
422
445
  return;
@@ -456,6 +479,23 @@ export async function hook(opts) {
456
479
  process.stdout.write(`${JSON.stringify(pass())}\n`);
457
480
  return;
458
481
  }
482
+ const { said, block } = await checkChanged(root, changed, mode, "command");
483
+ /**
484
+ * E O RELÓGIO ANDA AQUI, depois de olhar - `appendEvent` já o moveu para cada arquivo relatado,
485
+ * e esta linha cobre o caso em que todos os relatórios falharam na leitura.
486
+ */
487
+ await startClock(root);
488
+ const context = said.join("\n\n");
489
+ process.stdout.write(`${JSON.stringify(said.length === 0 ? pass() : block ? refuse(context) : speak(context))}\n`);
490
+ }
491
+ /**
492
+ * O QUE A RODADA MUDOU, CHECADO - o corpo que era só do caminho de shell, e que agora o fim do turno
493
+ * também usa. Os dois fazem a mesma pergunta ("o que foi escrito desde a última vez que olhei?"),
494
+ * e duas cópias da resposta divergiriam no primeiro conserto.
495
+ */
496
+ async function checkChanged(root, changed, mode,
497
+ /** O que a pessoa chama de rodada: o comando de shell, ou o turno inteiro do agente. */
498
+ round) {
459
499
  const said = [];
460
500
  /**
461
501
  * UMA RECUSA EM QUALQUER ARQUIVO DA RODADA RECUSA A RODADA - e é o lado certo: um comando de
@@ -518,17 +558,61 @@ export async function hook(opts) {
518
558
  }
519
559
  if (rest.length > 0)
520
560
  said.push([
521
- `${rest.length} more file${rest.length === 1 ? "" : "s"} changed in this command and ${rest.length === 1 ? "was" : "were"} not checked here: ${rest
561
+ `${rest.length} more file${rest.length === 1 ? "" : "s"} changed in this ${round} and ${rest.length === 1 ? "was" : "were"} not checked here: ${rest
522
562
  .slice(0, 10)
523
563
  .map((c) => c.rel)
524
564
  .join(", ")}${rest.length > 10 ? `, +${rest.length - 10} more` : ""}.`,
525
- "Run `npx synthesisui doctor` to see them, or write one of them again and this will check it.",
565
+ round === "turn"
566
+ ? "Run `npx synthesisui doctor` to see them - they are checked again the next turn one of them changes."
567
+ : "Run `npx synthesisui doctor` to see them, or write one of them again and this will check it.",
526
568
  ].join("\n"));
569
+ return { said, block };
570
+ }
571
+ /**
572
+ * O FIM DO TURNO - E01 da jornada "a checagem no fim do turno", escolha dele em 23/09: *"no fim de
573
+ * cada turno do agente, sobre o diff"*.
574
+ *
575
+ * UMA VOZ SÓ, E ELA SEGURA O TURNO. No `Stop`, a única saída que chega ao agente é `decision:
576
+ * "block"` com o motivo: o Claude Code devolve o motivo ao agente, e ele continua trabalhando em vez
577
+ * de encerrar. É o momento que a checagem por escrita não tinha - tudo o que o turno escreveu, numa
578
+ * mensagem só, antes de a resposta sair.
579
+ *
580
+ * E SEGURA UMA VEZ. Quando o turno já foi segurado, o Claude Code manda `stop_hook_active: true`, e
581
+ * este hook passa sem olhar. Sem isso, uma cor antiga que continua no arquivo - as do Hero do
582
+ * CodeLevel, medidas em 23/09 - prenderia o agente num laço que só a pessoa quebra.
583
+ *
584
+ * O RELÓGIO NÃO ANDA NESSA PASSAGEM, de propósito: o que o agente mudou para responder ao motivo
585
+ * fica mais novo que o relógio, e o próximo turno de verdade o checa. É assim que o ledger ganha a
586
+ * linha limpa que prova o conserto.
587
+ */
588
+ async function endOfTurn(root, input) {
527
589
  /**
528
- * E O RELÓGIO ANDA AQUI, depois de olhar - `appendEvent` já o moveu para cada arquivo relatado,
529
- * e esta linha cobre o caso em que todos os relatórios falharam na leitura.
590
+ * O SYNC, QUANDO ELA DISSE SIM - E03. Disparado antes de qualquer resposta e sem esperar: o turno
591
+ * não fica mais lento por causa da rede, e um sync que falha não custa a checagem. Sem o sim, esta
592
+ * linha não chega a tocar em rede nenhuma (ver `auto-sync.ts`).
530
593
  */
531
- await startClock(root);
532
- const context = said.join("\n\n");
533
- process.stdout.write(`${JSON.stringify(said.length === 0 ? pass() : block ? refuse(context) : speak(context))}\n`);
594
+ if ((await readAutoSync(root).catch(() => null)) === true)
595
+ await startSync(root).catch(() => false);
596
+ const out = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`);
597
+ if (input.stop_hook_active) {
598
+ out(pass());
599
+ return;
600
+ }
601
+ const clock = await turnClockOf(root);
602
+ /** Ver `startClock`: o primeiro fim de turno depois de instalado acerta o relógio e cala. */
603
+ if (clock === 0) {
604
+ await moveTurnClock(root);
605
+ out(pass());
606
+ return;
607
+ }
608
+ const ungoverned = await ungovernedIn(root);
609
+ const mode = await readMode(root).catch(() => null);
610
+ const changed = await changedSince(root, clock, (rel) => governs(rel, ungoverned)).catch(() => []);
611
+ const { said } = changed.length > 0
612
+ ? await checkChanged(root, changed, mode, "turn")
613
+ : { said: [] };
614
+ await moveTurnClock(root);
615
+ out(said.length === 0
616
+ ? pass()
617
+ : { decision: "block", reason: said.join("\n\n") });
534
618
  }
@@ -171,7 +171,7 @@ async function rewireIfBehind(root, cli) {
171
171
  mcp: true,
172
172
  agents: await agentsToMaintain(root),
173
173
  }).catch(() => null);
174
- console.log(`↻ the check after every write moved from ${pinned} to ${cli}`);
174
+ console.log(`↻ the end-of-turn check moved from ${pinned} to ${cli}`);
175
175
  console.log(" Reopen your editor session - hooks are read at startup.");
176
176
  }
177
177
  /**
@@ -32,7 +32,12 @@ register("pt-BR", {
32
32
  // ── o estado do repositório ──
33
33
  "· no design system installed here yet - the agent has no index, and memory has nothing to belong to.": "· nenhum design system instalado aqui ainda - o agente não tem índice, e a memória não tem a que pertencer.",
34
34
  // ── a fiação do agente ──
35
- "✓ .claude/settings.json the check now runs after every write": "✓ .claude/settings.json a verificação roda após cada escrita",
35
+ "✓ .claude/settings.json the check now runs at the end of each agent turn": "✓ .claude/settings.json a verificação roda no fim de cada turno do agente",
36
+ "✓ .claude/settings.json the check moved from every write to the end of each agent turn": "✓ .claude/settings.json a verificação saiu de cada escrita e foi para o fim de cada turno do agente",
37
+ "✓ what the check records is sent to your dashboard at the end of each turn": "✓ o que a verificação registra vai para o seu painel no fim de cada turno",
38
+ "· what the check records stays on this machine - run `npx synthesisui sync` to send it": "· o que a verificação registra fica nesta máquina - rode `npx synthesisui sync` para enviar",
39
+ "send what the check records to your dashboard at the end of each turn?": "enviar o que a verificação registra para o seu painel no fim de cada turno?",
40
+ "· Codex has no end-of-turn hook we can hold - run `npx synthesisui doctor` to check what it wrote": "· o Codex não tem um fim de turno que a gente possa segurar - rode `npx synthesisui doctor` para checar o que ele escreveu",
36
41
  "· .claude/settings.json already had it": "· .claude/settings.json já estava lá",
37
42
  "✓ .claude/settings.json each session opens with what is missing": "✓ .claude/settings.json cada sessão abre com o que falta",
38
43
  "✓ .claude/settings.json the session check moved to this one": "✓ .claude/settings.json a verificação de sessão veio para esta",
@@ -53,7 +58,7 @@ register("pt-BR", {
53
58
  "Selected: {agents}": "Selecionados: {agents}",
54
59
  " our part in {files} sits between `synthesisui:start` and `synthesisui:end` - the rest of each file is yours": " a nossa parte em {files} fica entre `synthesisui:start` e `synthesisui:end` - o resto de cada arquivo é seu",
55
60
  " in {files} only our `synthesisui` entry is ours - the rest of the file is yours": " em {files} só a nossa entrada `synthesisui` é nossa - o resto do arquivo é seu",
56
- " the write check inside it keeps running until that entry is gone": " a verificação de escrita dentro dele continua rodando até aquela entrada sair",
61
+ " the turn check inside it keeps running until that entry is gone": " a verificação do fim do turno dentro dele continua rodando até aquela entrada sair",
57
62
  " the rules file is ours to delete; in `.cursor/mcp.json` only the `synthesisui` entry is - the other servers there are yours": " o arquivo de regras é nosso para apagar; no `.cursor/mcp.json` só a entrada `synthesisui` é - os outros servidores ali são seus",
58
63
  "· not an agent this CLI knows: {names}": "· não é um agente que este CLI conhece: {names}",
59
64
  "· no agent chosen, and nothing here says which agent you use - run with `--agents claude` or `--agents codex`, or run this in a terminal to pick from the list": "· nenhum agente escolhido, e nada aqui diz qual agente você usa - rode com `--agents claude` ou `--agents codex`, ou rode isto num terminal para escolher da lista",
@@ -117,12 +122,12 @@ register("pt-BR", {
117
122
  "then, to pick up where you left off:": "e depois, para voltar de onde você parou:",
118
123
  // ── os nomes do que moveu, usados dentro da frase acima ──
119
124
  "the checks": "as verificações",
120
- "the write check": "a verificação de escrita",
125
+ "the turn check": "a verificação do fim do turno",
121
126
  "the session check": "a verificação de sessão",
122
127
  "the tools": "as ferramentas",
123
128
  "the skills": "as skills",
124
129
  // ── o custo do hook ──
125
- "The hook resolves against the npm registry on each edit - that is the wait, not the check. Add synthesisui to your devDependencies and run this again; it switches by itself to local, several times faster.": "O hook resolve no registro do npm a cada edição - a espera é isso, não a verificação. Adicione synthesisui às suas devDependencies e rode isto de novo; ele passa a resolver local sozinho, várias vezes mais rápido.",
130
+ "The hook resolves against the npm registry at the end of each turn - that is the wait, not the check. Add synthesisui to your devDependencies and run this again; it switches by itself to local, several times faster.": "O hook resolve no registro do npm no fim de cada turno - a espera é isso, não a verificação. Adicione synthesisui às suas devDependencies e rode isto de novo; ele passa a resolver local sozinho, várias vezes mais rápido.",
126
131
  // ── até onde a garantia vai (uma vez, e de novo só quando a resposta muda) ──
127
132
  "How far this check goes here": "Até onde esta verificação vai aqui",
128
133
  "Every file this project writes is checked as it is written - by an edit tool or by a shell command, it makes no difference.": "Todo arquivo que este projeto escreve é verificado no momento da escrita - por uma ferramenta de edição ou por um comando de shell, dá no mesmo.",
@@ -130,8 +135,8 @@ register("pt-BR", {
130
135
  "What a file no longer has is measured against your last commit. A file git has never seen has no before, so nothing is reported as removed there.": "O que um arquivo deixou de ter é medido contra o seu último commit. Um arquivo que o git nunca viu não tem um antes, então nada é reportado como removido nele.",
131
136
  "A value counts as coming from your system in two spellings: `var(--token)`, and the {n} utility names your own theme generates - `bg-primary`, `p-6`, `rounded-lg`.": "Um valor conta como vindo do seu sistema em duas grafias: `var(--token)`, e os {n} nomes de utility que o seu próprio tema gera - `bg-primary`, `p-6`, `rounded-lg`.",
132
137
  "A value counts as coming from your system when it is written as `var(--token)`. This system declares no theme names, so a utility class is not read as a reference to it.": "Um valor conta como vindo do seu sistema quando é escrito como `var(--token)`. Este sistema não declara nomes de tema, então uma classe utility não é lida como referência a ele.",
133
- "Every file in this project is checked as you write it, except {out}, which you named under `ungoverned` in `_synthesisui/config.json`.": "Todo arquivo deste projeto é verificado no momento em que você o escreve, menos {out}, que você escreveu em `ungoverned` no `_synthesisui/config.json`.",
134
- "Every file in this project is checked as you write it. To leave a place out, name it under `ungoverned` in `_synthesisui/config.json`.": "Todo arquivo deste projeto é verificado no momento em que você o escreve. Para deixar um lugar de fora, escreva-o em `ungoverned` no `_synthesisui/config.json`.",
138
+ "Every file in this project is checked at the end of each agent turn, except {out}, which you named under `ungoverned` in `_synthesisui/config.json`.": "Todo arquivo deste projeto é verificado no fim de cada turno do agente, menos {out}, que você escreveu em `ungoverned` no `_synthesisui/config.json`.",
139
+ "Every file in this project is checked at the end of each agent turn. To leave a place out, name it under `ungoverned` in `_synthesisui/config.json`.": "Todo arquivo deste projeto é verificado no fim de cada turno do agente. Para deixar um lugar de fora, escreva-o em `ungoverned` no `_synthesisui/config.json`.",
135
140
  "What this system knows was read from {scope}. {apps} declare a dependency on `{specifier}`, so they write in its vocabulary - nothing in them was read.": "O que este sistema sabe foi lido de {scope}. {apps} declaram dependência de `{specifier}`, então escrevem no vocabulário dele - nada dentro deles foi lido.",
136
141
  "What this system knows was read from {scope} - nothing outside it was opened.": "O que este sistema sabe foi lido de {scope} - nada fora dali foi aberto.",
137
142
  "A value this ruler cannot read is not a value it approved - silence here is never a pass.": "Um valor que esta régua não consegue ler não é um valor que ela aprovou - silêncio aqui nunca é aprovação.",
@@ -274,6 +274,40 @@ function importedNames(source, internal = []) {
274
274
  */
275
275
  const BOUND = /(?:const|let|var|function|class)\s+([A-Z][A-Za-z0-9_]*)|[{,]\s*\w+\s*:\s*([A-Z][A-Za-z0-9_]*)\s*(?:=[^,}]*)?\s*[,}]/g;
276
276
  const EXPORTED_NAME = /export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z][A-Za-z0-9_]*)|export\s+default\s+([A-Z][A-Za-z0-9_]*)|export\s*\{([^}]*)\}/g;
277
+ /**
278
+ * OS COMPONENTES QUE ESTE ARQUIVO EXPORTA - a metade que faltava para o hook
279
+ * enxergar a PRATELEIRA, e não só os valores.
280
+ *
281
+ * O hook já dizia "este valor tem nome no sistema". Ele nunca dizia "esta PEÇA
282
+ * tem recipe no sistema", e o agente do CodeLevel mediu o efeito disso em
283
+ * 22/09: escreveu cinco componentes do zero num dia, com pelo menos quatro
284
+ * deles já existindo no índice - *"a ferramenta estava ligada o dia inteiro e
285
+ * eu não fiz uma pergunta a ela"*.
286
+ *
287
+ * Reusa a mesma regex de `localOnlyNames`, que é quem já sabe ler as quatro
288
+ * formas de exportar. Duas leituras do mesmo formato discordariam um dia.
289
+ */
290
+ export function exportedNames(source) {
291
+ const nomes = new Set();
292
+ EXPORTED_NAME.lastIndex = 0;
293
+ for (const m of source.matchAll(EXPORTED_NAME)) {
294
+ if (m[1])
295
+ nomes.add(m[1]);
296
+ if (m[2])
297
+ nomes.add(m[2]);
298
+ if (m[3]) {
299
+ for (const cru of m[3].split(",")) {
300
+ const nome = cru
301
+ .trim()
302
+ .split(/\s+as\s+/)[0]
303
+ ?.trim();
304
+ if (nome && /^[A-Z]/.test(nome))
305
+ nomes.add(nome);
306
+ }
307
+ }
308
+ }
309
+ return nomes;
310
+ }
277
311
  export function localOnlyNames(source) {
278
312
  const exported = new Set();
279
313
  EXPORTED_NAME.lastIndex = 0;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A PRATELEIRA - "esta peça já existe no sistema", dito sem ninguém perguntar.
3
+ *
4
+ * O PROBLEMA, medido em 22/09 no CodeLevel. O agente escreveu cinco componentes
5
+ * do zero num dia; pelo menos quatro já existiam no índice do sistema. Ele
6
+ * mesmo contou o porquê: *"o AGENTS.md manda consultar o índice antes de
7
+ * escrever UI (...) não consultei uma única vez. Também não chamei nenhuma das
8
+ * ferramentas dele"*. E fechou: *"a ferramenta estava ligada o dia inteiro e eu
9
+ * não fiz uma pergunta a ela"*.
10
+ *
11
+ * As duas metades do produto têm caminhos diferentes, e é isso que explica o
12
+ * resultado: a GUARDA roda no hook, sem pedir licença, e funcionou 209 de 209.
13
+ * A BIBLIOTECA é MCP - `list_components`, `describe_component` -, e MCP é PULL:
14
+ * só acontece se o agente resolver perguntar. Ele não resolveu nenhuma vez.
15
+ *
16
+ * Isto põe a biblioteca no caminho de push.
17
+ *
18
+ * ─────────────────────────────────────────────────────────────────────────
19
+ * A DISCIPLINA, e ela é a parte importante deste arquivo.
20
+ *
21
+ * Casar NOME é a família de defeito que este repositório já pagou caro: nome
22
+ * vencendo a leitura foi o erro do importador. Então aqui:
23
+ *
24
+ * IGUALDADE EXATA, depois de normalizar. Nada de substring, prefixo,
25
+ * distância de edição ou plural. `Card` casa com `card`; `CardHeader` não
26
+ * casa com `card`, e é melhor perder o aviso do que dar um errado.
27
+ *
28
+ * É PERGUNTA, NUNCA VEREDITO. O texto não diz "você errou": diz que os dois
29
+ * nomes coincidem e pergunta se são a mesma coisa.
30
+ *
31
+ * A CHECAGEM DECLARA A PRÓPRIA FRAQUEZA na saída. Quem lê é um agente, e um
32
+ * agente que recebe "isto já existe" como fato vai apagar trabalho certo.
33
+ */
34
+ /** `StatCard` → `stat-card`; `DSCard` → `ds-card`. */
35
+ export function normalizar(nome) {
36
+ return nome
37
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
38
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
39
+ .toLowerCase();
40
+ }
41
+ /** Os componentes que o sistema declara, de um documento de design system. */
42
+ export function declarados(documents) {
43
+ const nomes = new Set();
44
+ for (const doc of documents) {
45
+ const comps = doc?.components;
46
+ if (comps)
47
+ for (const k of Object.keys(comps))
48
+ nomes.add(normalizar(k));
49
+ const blocos = doc?.blocks;
50
+ if (blocos)
51
+ for (const k of Object.keys(blocos))
52
+ nomes.add(normalizar(k));
53
+ }
54
+ return nomes;
55
+ }
56
+ /**
57
+ * O que este arquivo define E o sistema já declara. Vazio é a resposta normal.
58
+ *
59
+ * `ds-` some do nome declarado antes de comparar: o sistema guarda `card` e a
60
+ * classe emitida é `ds-card`, e um repositório que chama a peça dele de `DsCard`
61
+ * está falando do mesmo componente.
62
+ */
63
+ export function naPrateleira(exportados, sistema) {
64
+ const achados = [];
65
+ for (const nome of exportados) {
66
+ const n = normalizar(nome);
67
+ const semPrefixo = n.startsWith("ds-") ? n.slice(3) : n;
68
+ if (sistema.has(n))
69
+ achados.push({ escrito: nome, declarado: n });
70
+ else if (sistema.has(semPrefixo))
71
+ achados.push({ escrito: nome, declarado: semPrefixo });
72
+ }
73
+ return achados;
74
+ }
75
+ /** O texto que o agente lê. Pergunta, com a fraqueza declarada. */
76
+ export function falaDaPrateleira(rel, achados, sistema) {
77
+ if (achados.length === 0)
78
+ return [];
79
+ return [
80
+ `${rel} - ${sistema} already declares ${achados.length === 1 ? "a component" : "components"} with ${achados.length === 1 ? "this name" : "these names"}:`,
81
+ ...achados.map((a) => ` you wrote ${a.escrito} · the system has ${a.declarado}`),
82
+ "",
83
+ "If they are the same thing, materialize the system's instead of keeping yours - it arrives already wearing the tokens, and it stays in step when the system changes.",
84
+ /**
85
+ * A FRAQUEZA VAI JUNTO. Esta checagem compara NOME, e nome colide: um
86
+ * `Card` de dashboard e um `card` de baralho são palavras iguais e coisas
87
+ * diferentes. Sem esta linha, um agente obediente apaga trabalho certo.
88
+ */
89
+ "This check compares NAMES only, and names collide. If yours is a different thing, keep it and say so in your summary.",
90
+ ];
91
+ }
package/dist/guarantee.js CHANGED
@@ -120,10 +120,10 @@ system) {
120
120
  */
121
121
  lines.push(out.length > 0
122
122
  ? {
123
- text: "Every file in this project is checked as you write it, except {out}, which you named under `ungoverned` in `_synthesisui/config.json`.",
123
+ text: "Every file in this project is checked at the end of each agent turn, except {out}, which you named under `ungoverned` in `_synthesisui/config.json`.",
124
124
  values: { out: out.map((p) => `\`${p}\``).join(", ") },
125
125
  }
126
- : plain("Every file in this project is checked as you write it. To leave a place out, name it under `ungoverned` in `_synthesisui/config.json`."));
126
+ : plain("Every file in this project is checked at the end of each agent turn. To leave a place out, name it under `ungoverned` in `_synthesisui/config.json`."));
127
127
  /**
128
128
  * E DE ONDE O SISTEMA FOI LIDO - outra pergunta, e por isso outra frase.
129
129
  *
@@ -235,7 +235,24 @@
235
235
  * chama `wireAgent`, então rodar o comando é o caminho de volta - e ele só alarga a string que era
236
236
  * nossa, nunca um filtro que uma pessoa escreveu.
237
237
  */
238
- export const MATERIALISER_SINCE = "0.16.429";
238
+ /**
239
+ * 0.16.429 -> 0.16.453 em 23/09, e o passo 1 dá **SIM**: muda um byte que cai no `.claude/settings.json`
240
+ * dele. O hook sai do `PostToolUse` e vai para o `Stop` - a checagem passa a rodar no fim do turno do
241
+ * agente (E02 da jornada "a checagem no fim do turno"). Quem conectou antes tem o hook por escrita, e
242
+ * sem rodar o comando fica nele: a checagem continua a cada arquivo, e o fim do turno nunca é
243
+ * checado. É o mesmo caso de 12/09 acima: o `upgrade` chama `wireAgent`, e rodá-lo é o caminho.
244
+ */
245
+ /**
246
+ * 0.16.453 -> 0.16.454, ainda em 23/09, e o passo 1 dá **SIM** outra vez: o `.gitignore` da pasta dele
247
+ * ganha `.auto-sync`, `.sync-started` e `.turn-clock` (E03). Sem o comando, o relógio do fim do turno
248
+ * aparece no `git status` dele no primeiro turno - um arquivo nosso que ele commitaria por engano.
249
+ */
250
+ /**
251
+ * 0.16.454 -> 0.16.455, ainda em 23/09, e o passo 1 dá **SIM**: o bloco gerenciado do `CLAUDE.md` dele
252
+ * dizia que o hook "reports on every file you write" - a instrução que o agente dele lê sobre o próprio
253
+ * mecanismo. Passa a dizer o fim do turno (E04), e só o `upgrade` reescreve o bloco.
254
+ */
255
+ export const MATERIALISER_SINCE = "0.16.455";
239
256
  /**
240
257
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
241
258
  *
@@ -366,7 +383,14 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
366
383
  * utility, um arquivo escrito inteiro no vocabulário do sistema recebia zero. A contagem em si é a
367
384
  * outra marca - ver `COUNTED_DIFFERENTLY_SINCE`, que sobe no mesmo diff.
368
385
  */
369
- export const CHECKER_SINCE = "0.16.424";
386
+ /**
387
+ * 0.16.424 -> 0.16.454 em 23/09, e o passo 1 dá **SIM**: o hook passa a disparar o `sync` no fim do
388
+ * turno quando ela disse sim no `connect` (E03 da jornada "a checagem no fim do turno"). Um hook
389
+ * anterior - uma instalação local mais velha que o `connect` que fez a pergunta - lê o mesmo arquivo
390
+ * e não faz nada com ele: o painel continuaria atrasado com o sim dado. É o interruptor que parece
391
+ * ligado e não está, o mesmo caso do CONSUMIR em 12/09.
392
+ */
393
+ export const CHECKER_SINCE = "0.16.454";
370
394
  /**
371
395
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
372
396
  *
@@ -158,7 +158,7 @@ export function oQueMuda(c) {
158
158
  const aMao = observados.filter((v) => !v.token);
159
159
  if (aMao.length === 0)
160
160
  return [
161
- "Every design value in your code already comes from a token you declare. What SynthesisUI adds here is keeping it that way: the check runs after every file your agent writes, and a value that drifts off your own scale comes back to you as a decision instead of landing silently.",
161
+ "Every design value in your code already comes from a token you declare. What SynthesisUI adds here is keeping it that way: the check runs at the end of every agent turn, and a value that drifts off your own scale comes back to you as a decision instead of landing silently.",
162
162
  ];
163
163
  const repetidos = aMao.filter((v) => v.count > 1);
164
164
  const usos = repetidos.reduce((n, v) => n + v.count, 0);
@@ -166,7 +166,7 @@ export function oQueMuda(c) {
166
166
  linhas.push(`${aMao.length} of your design values are written by hand today. Importing gives each one a name in your own vocabulary, so changing it later means changing it once instead of finding every copy.`);
167
167
  if (repetidos.length > 0)
168
168
  linhas.push(`${repetidos.length} of those are repeated - ${usos} uses across your files. Every copy is a place the next change can be forgotten, which is how two screens end up almost the same colour.`);
169
- linhas.push("And it stops coming back: once the system is in the repository, the check runs after every file your agent writes and catches a value that drifts off your own scale, before it ships.");
169
+ linhas.push("And it stops coming back: once the system is in the repository, the check runs at the end of every agent turn and catches a value that drifts off your own scale, before it ships.");
170
170
  return linhas;
171
171
  }
172
172
  /**
@@ -193,7 +193,7 @@ export function passos() {
193
193
  {
194
194
  comando: "npx synthesisui@latest connect",
195
195
  titulo: "Wire your agent",
196
- porque: "Installs the check that runs after every file your agent writes, the tools it can query, and the skills that know this pipeline. Nothing of your code is touched or sent. Run it before you open the agent - a session only picks these up when it starts.",
196
+ porque: "Installs the check that runs at the end of every agent turn, the tools it can query, and the skills that know this pipeline. Nothing of your code is touched or sent. Run it before you open the agent - a session only picks these up when it starts.",
197
197
  },
198
198
  {
199
199
  comando: "npx synthesisui@latest import",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.450",
3
+ "version": "0.16.455",
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": {