synthesisui 0.16.500 → 0.16.502
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.
- package/dist/claude-md.js +20 -5
- package/dist/commands/add.js +2 -0
- package/dist/commands/connect.js +7 -3
- package/dist/commands/hook.js +165 -5
- package/dist/commands/init.js +18 -5
- package/dist/commands/read.js +58 -6
- package/dist/door.js +176 -0
- package/dist/index.js +7 -2
- package/dist/install-marks.js +13 -2
- package/dist/role-rules.js +138 -5
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { loadRoleRules, rolesContract } from "./role-rules.js";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
@@ -5,6 +6,13 @@ import { hasHook } from "./agent-wiring.js";
|
|
|
5
6
|
import { declaredReference } from "./group-role.js";
|
|
6
7
|
import { recallAvailable } from "./memory/availability.js";
|
|
7
8
|
import { readResponsiveForm, responsiveLine } from "./responsive-form.js";
|
|
9
|
+
/**
|
|
10
|
+
* A LEITURA - o caminho do sistema desde a fase 1 (18/10). Escrita uma vez e usada nos dois contratos: o de quem já tem
|
|
11
|
+
* sistema sem `rules.json` e o de quem chegou pela porta e ainda não tem sistema nenhum (fase 2).
|
|
12
|
+
*/
|
|
13
|
+
const READ_THE_PAGE = "**Once the first screen is done, offer to read it - once.** If their next message does not take the offer up, do not offer it again; they can still ask. When the person asks you to read their page - or right after the creation of the first screen ends (with impeccable, once it has written `DESIGN.md`; never while it still asks for another round) - run `npx synthesisui read` with the dev server up, tell them in a few lines the system it found, then ask the questions in `_synthesisui/reading.json` ONE AT A TIME with your question tool (the first option is the default). Save their answers as `{ \"<question id>\": <option index> }` and run `npx synthesisui read --confirm <that file>`: their answers become the roles and breaks this project keeps.";
|
|
14
|
+
/** A frase do revisor depois da criação - ver o comentário em `renderRegion`, junto dos papéis. */
|
|
15
|
+
const REVIEWER_AFTER_CREATION = "\n\n**How a change closes here.** The visual world is decided - the roles and breaks above. From here on SynthesisUI's check closes a change (`check_file`, and the end-of-turn check). Impeccable's reviewer is for creating: spawn `impeccable-finish-reviewer` or run `impeccable detect` only when the person asks for a new visual direction or a large new surface with its own look - never as the routine close of a change, and never its degraded self-review in its place.";
|
|
8
16
|
const START = "<!-- synthesisui:start -->";
|
|
9
17
|
const END = "<!-- synthesisui:end -->";
|
|
10
18
|
/** Reads the installed DSs from the .lock files in _synthesisui/ds/<slug>/. */
|
|
@@ -337,9 +345,10 @@ async function renderRegion(projectRoot, installed) {
|
|
|
337
345
|
"This project has the synthesisui agent set up and **no design system yet** - so there is",
|
|
338
346
|
"no token vocabulary to follow, and nothing here to obey.",
|
|
339
347
|
"",
|
|
340
|
-
|
|
341
|
-
"
|
|
342
|
-
"
|
|
348
|
+
READ_THE_PAGE,
|
|
349
|
+
"",
|
|
350
|
+
"A system kept somewhere else - a published one, or a repository's own - comes in with",
|
|
351
|
+
"`/sui-import-ds` instead: it reads what is already in the code. Nothing is invented.",
|
|
343
352
|
"",
|
|
344
353
|
"The `synthesisui` MCP server is registered, so ASK it instead of guessing - it",
|
|
345
354
|
"answers what exists and what does not. This block fills itself in once a system is",
|
|
@@ -469,12 +478,18 @@ written now** - read the code before you edit it, never a remembered signature.`
|
|
|
469
478
|
* livres passa pelo `landing` antes de qualquer linha - ele lista as seções do catálogo e devolve o prompt da pilha, com
|
|
470
479
|
* as decisões tomadas. Sem servidor também: o `landing` fala com o registro direto.
|
|
471
480
|
*/
|
|
481
|
+
/**
|
|
482
|
+
* O REVISOR DA CAMADA DE ESTÉTICA SÓ NA CRIAÇÃO - decisão dele, 18/10 15:23 ("só na criação"). Antes da confirmação o
|
|
483
|
+
* impeccable segue as próprias instruções, revisor incluído: o mundo ainda está sendo criado. Com o sistema confirmado,
|
|
484
|
+
* fecha a mudança a nossa checagem. Pela instrução, nunca apagando o agente dele - compor, não forkar (15:25). A medida
|
|
485
|
+
* que motivou: no fluxo com o revisor em todo turno, 52 de 103 min e metade dos tokens (estudo de 18/10, seção 12).
|
|
486
|
+
*/
|
|
472
487
|
/** Os papéis e as quebras que a página dele já decidiu - ver `role-rules.ts` (18/10). Só existe com `rules.json`. */
|
|
473
488
|
const roleRules = await loadRoleRules(projectRoot).catch(() => null);
|
|
474
489
|
const rolesLine = roleRules
|
|
475
|
-
? rolesContract(roleRules)
|
|
490
|
+
? rolesContract(roleRules) + (existsSync(join(projectRoot, ".claude", "skills", "impeccable")) ? REVIEWER_AFTER_CREATION : "")
|
|
476
491
|
: /** Sem rules.json ainda: o agente oferece a leitura (jornada "a leitura no agente", E03, 18/10). */
|
|
477
|
-
|
|
492
|
+
`\n\n${READ_THE_PAGE}`;
|
|
478
493
|
const landingLine = '\n\n**A landing asked in free words:** run `npx synthesisui@latest landing <slug> "<their words>"` first, and follow it.';
|
|
479
494
|
const preferencesLine = recall.available
|
|
480
495
|
? `
|
package/dist/commands/add.js
CHANGED
|
@@ -81,6 +81,8 @@ const IGNORED = [
|
|
|
81
81
|
* da mesma versão, em branches separadas, darem conflito numa linha só.
|
|
82
82
|
*/
|
|
83
83
|
".fetched-at.json",
|
|
84
|
+
/** A oferta da leitura, feita uma vez neste clone - ver `offerTheReading` (fase 2, 18/10). */
|
|
85
|
+
".read-offered",
|
|
84
86
|
];
|
|
85
87
|
const IGNORE_HEADER = "# Managed by synthesisui. The identity and the CSS are committed so a fresh\n" +
|
|
86
88
|
"# clone is governed; the measurement and the local record are not, because\n" +
|
package/dist/commands/connect.js
CHANGED
|
@@ -316,7 +316,10 @@ export async function connect(opts) {
|
|
|
316
316
|
* A PERGUNTA SÓ EXISTE ONDE HÁ ALGUÉM PARA RESPONDER. Em `--ci`, num pipe ou dentro de um agente,
|
|
317
317
|
* `ask` vai `null` e a pasta decide - e a linha DIZ que foi ela, porque silêncio nunca é escolha.
|
|
318
318
|
*/
|
|
319
|
-
const interactive = !opts.ci &&
|
|
319
|
+
const interactive = !opts.ci &&
|
|
320
|
+
opts.interactive !== false &&
|
|
321
|
+
Boolean(process.stdin.isTTY) &&
|
|
322
|
+
Boolean(process.stdout.isTTY);
|
|
320
323
|
const chosen = await chooseAgents({
|
|
321
324
|
root,
|
|
322
325
|
flag: opts.agents,
|
|
@@ -471,7 +474,7 @@ export async function connect(opts) {
|
|
|
471
474
|
*/
|
|
472
475
|
if (want.hook && wired.hook !== "skipped") {
|
|
473
476
|
const before = await readAutoSync(root).catch(() => null);
|
|
474
|
-
const now = before ?? (await askToSendAtTurnEnd());
|
|
477
|
+
const now = before ?? (opts.interactive === false ? null : await askToSendAtTurnEnd());
|
|
475
478
|
if (before === null && now !== null)
|
|
476
479
|
await writeAutoSync(root, now);
|
|
477
480
|
row(before === null, now === true
|
|
@@ -784,7 +787,8 @@ export async function connect(opts) {
|
|
|
784
787
|
}
|
|
785
788
|
}
|
|
786
789
|
/** A mesma versão que a fiação do editor recebe - os dois pinam no mesmo número. */
|
|
787
|
-
|
|
790
|
+
if (opts.interactive !== false)
|
|
791
|
+
await offerShellHook(opts.shell === true, opts.version);
|
|
788
792
|
/**
|
|
789
793
|
* O REINÍCIO SÓ QUANDO ELE É NECESSÁRIO - e pedi-lo sempre é o que fez o dono achar que reiniciar
|
|
790
794
|
* o editor fazia parte do fluxo (07/08).
|
package/dist/commands/hook.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { ourSpellingLines } from "../our-spelling.js";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { readAutoSync, startSync } from "../auto-sync.js";
|
|
6
6
|
import { changedSince } from "../changed-files.js";
|
|
@@ -317,7 +317,7 @@ async function report(root, filePath, mode) {
|
|
|
317
317
|
const ours = d.tokenUses;
|
|
318
318
|
/** Os papéis e as quebras que a página dele já decidiu - ver `role-rules.ts` (18/10). */
|
|
319
319
|
const roleRules = await loadRoleRules(root).catch(() => null);
|
|
320
|
-
const roles = roleRules ? await onlyOnce(root, rel, checkRoles(rel, src, roleRules)) : [];
|
|
320
|
+
const roles = roleRules ? await onlyOnce(root, rel, checkRoles(rel, src, roleRules, root)) : [];
|
|
321
321
|
// Every check leaves one line in the ledger - INCLUDING the clean ones,
|
|
322
322
|
// because a fix is itself a write, so the clean re-check of a file that was
|
|
323
323
|
// just flagged is the evidence the agent read the report and acted. Without
|
|
@@ -743,7 +743,11 @@ async function endOfTurn(root, input) {
|
|
|
743
743
|
await startSync(root).catch(() => false);
|
|
744
744
|
const out = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`);
|
|
745
745
|
if (input.stop_hook_active) {
|
|
746
|
-
|
|
746
|
+
/** A única exceção à passagem calada: a oferta que ainda não chegou à resposta final - ver `offerTheReading`. */
|
|
747
|
+
const again = existsSync(join(root, "_synthesisui", READ_OFFERED_FILE))
|
|
748
|
+
? await offerTheReading(root, [], input.transcript_path).catch(() => null)
|
|
749
|
+
: null;
|
|
750
|
+
out(again ? { decision: "block", reason: again } : pass());
|
|
747
751
|
return;
|
|
748
752
|
}
|
|
749
753
|
const clock = await turnClockOf(root);
|
|
@@ -763,10 +767,166 @@ async function endOfTurn(root, input) {
|
|
|
763
767
|
const grown = await takeGrowthNote(root).catch(() => null);
|
|
764
768
|
if (grown)
|
|
765
769
|
said.push(grown);
|
|
770
|
+
const offer = await offerTheReading(root, changed, input.transcript_path, clock).catch(() => null);
|
|
766
771
|
await moveTurnClock(root);
|
|
767
|
-
out(said.length === 0
|
|
772
|
+
out(said.length === 0 && !offer
|
|
768
773
|
? pass()
|
|
769
|
-
: { decision: "block", reason: [...said, CLOSE_THE_TURN].join("\n\n") });
|
|
774
|
+
: { decision: "block", reason: [...said, offer ?? CLOSE_THE_TURN].join("\n\n") });
|
|
775
|
+
}
|
|
776
|
+
/** O arquivo que lembra que a oferta já foi feita neste clone - ignorado pelo git (ver `add.ts`). */
|
|
777
|
+
export const READ_OFFERED_FILE = ".read-offered";
|
|
778
|
+
/** Uma tela: o que o agente escreve quando faz interface. CSS conta - a primeira tela do impeccable mexe no globals. */
|
|
779
|
+
const SCREEN = /\.(tsx|jsx|vue|svelte|astro|html|css|scss)$/;
|
|
780
|
+
/**
|
|
781
|
+
* A LEITURA OFERECIDA SOZINHA - E03 da jornada "um instalador, uma porta" (fase 2, 18/10). O dono: *"o agente oferece a
|
|
782
|
+
* leitura sozinho depois da primeira tela"*. A linha do contrato já dizia "depois da primeira tela, ofereça" - e o agente
|
|
783
|
+
* só oferecia quando a pessoa pedia (medido na fase 1). O fim do turno é o único momento que vê o turno inteiro, então é
|
|
784
|
+
* ele quem pede.
|
|
785
|
+
*
|
|
786
|
+
* QUANDO A CRIAÇÃO ACABOU, NÃO NO MEIO DELA - decisão dele em 19/10, depois de seis rodadas reais. O impeccable tem um
|
|
787
|
+
* fim: a última coisa que ele escreve é o `DESIGN.md` (1-2 min antes do fim nas 3 rodadas que terminaram, porta-1, 3 e 6;
|
|
788
|
+
* ausente nas 3 em que a criação ainda perguntava "outra rodada?", porta-2, 4 e 5). Oferecer antes disso punha duas
|
|
789
|
+
* perguntas na mesma resposta, e a do impeccable ganhava. Então: com o impeccable no projeto, o sinal é o `DESIGN.md`
|
|
790
|
+
* escrito; sem ele, a primeira tela.
|
|
791
|
+
*
|
|
792
|
+
* FEITA É QUANDO ESTÁ NA RESPOSTA FINAL. Enquanto a última resposta do agente não oferece a leitura, o fim do turno pede de
|
|
793
|
+
* novo - três vezes no máximo. Depois de oferecida, ela vale até a PESSOA falar: na porta-6 a oferta saiu certa e os
|
|
794
|
+
* subagentes do impeccable acordaram o agente mais 4 vezes, e a resposta final ficou sem ela. Enquanto a pessoa não falou
|
|
795
|
+
* e a última resposta não tem a oferta, ela volta no fim (cinco renovações no máximo, a trava contra laço). Quando a
|
|
796
|
+
* pessoa fala, acabou: a recusa não vira insistência.
|
|
797
|
+
*
|
|
798
|
+
* SÓ NA PORTA: projeto sem sistema nenhum. Quem já tem um (do catálogo, importado, ou o da leitura com `rules.json`)
|
|
799
|
+
* escolheu o caminho dele (achado na suíte, 18/10: o fecho do turno de um sistema instalado passou a terminar na oferta).
|
|
800
|
+
* Sem o registro da conversa não há como ler a resposta - e aí se pede uma vez só.
|
|
801
|
+
*/
|
|
802
|
+
const MAX_OFFERS = 3;
|
|
803
|
+
const MAX_RENEWALS = 5;
|
|
804
|
+
export async function offerTheReading(root, changed, transcript, since = 0) {
|
|
805
|
+
const ds = join(root, "_synthesisui", "ds");
|
|
806
|
+
if (existsSync(ds) && readdirSync(ds).some((d) => statSync(join(ds, d)).isDirectory()))
|
|
807
|
+
return null;
|
|
808
|
+
const marker = join(root, "_synthesisui", READ_OFFERED_FILE);
|
|
809
|
+
const save = (s) => writeFile(marker, `${JSON.stringify(s)}\n`);
|
|
810
|
+
if (!existsSync(marker)) {
|
|
811
|
+
if (!(await creationDone(root, changed, since)))
|
|
812
|
+
return null;
|
|
813
|
+
await save({ count: 1, at: Date.now() });
|
|
814
|
+
return OFFER;
|
|
815
|
+
}
|
|
816
|
+
let state = null;
|
|
817
|
+
try {
|
|
818
|
+
state = JSON.parse(await readFile(marker, "utf8"));
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
if (!state || !transcript)
|
|
824
|
+
return null;
|
|
825
|
+
if (state.delivered) {
|
|
826
|
+
if ((state.renewals ?? 0) >= MAX_RENEWALS)
|
|
827
|
+
return null;
|
|
828
|
+
if (await lastReplyOffers(transcript))
|
|
829
|
+
return null;
|
|
830
|
+
if (typeof state.deliveredAt !== "number" || (await personSpokeAfter(transcript, state.deliveredAt))) {
|
|
831
|
+
await save({ ...state, renewals: MAX_RENEWALS });
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
await save({ ...state, renewals: (state.renewals ?? 0) + 1 });
|
|
835
|
+
return `You were woken up again after offering it, so it is no longer at the end of your reply. ${OFFER}`;
|
|
836
|
+
}
|
|
837
|
+
if ((state.count ?? MAX_OFFERS) >= MAX_OFFERS)
|
|
838
|
+
return null;
|
|
839
|
+
if (await lastReplyOffers(transcript)) {
|
|
840
|
+
await save({ ...state, delivered: true, deliveredAt: Date.now() });
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
const count = (state.count ?? 0) + 1;
|
|
844
|
+
await save({ ...state, count, at: Date.now() });
|
|
845
|
+
return `Your last reply did not offer it. ${OFFER}`;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* A CRIAÇÃO ACABOU NESTE TURNO? Com o impeccable (a skill que o instalador DELE escreve), o `DESIGN.md` na raiz, e escrito
|
|
849
|
+
* ou acompanhado de uma tela neste turno - um `DESIGN.md` antigo, sem tela nova, não é fim de criação nenhuma. Sem o
|
|
850
|
+
* impeccable, a primeira tela.
|
|
851
|
+
*/
|
|
852
|
+
async function creationDone(root, changed, since) {
|
|
853
|
+
const screen = changed.some((c) => SCREEN.test(c.rel));
|
|
854
|
+
if (!existsSync(join(root, ".claude", "skills", "impeccable")))
|
|
855
|
+
return screen;
|
|
856
|
+
const design = await stat(join(root, "DESIGN.md")).catch(() => null);
|
|
857
|
+
if (!design?.isFile())
|
|
858
|
+
return false;
|
|
859
|
+
return screen || design.mtimeMs >= since;
|
|
860
|
+
}
|
|
861
|
+
const OFFER = "The creation of this project's first screen is done, and it has no system yet. Answer anything above in a line or two, then the summary of what this turn did - and END your reply with ONE sentence offering to read the page and turn what it already decided into their design system (about a minute, a few questions). That sentence is the LAST line, after any question of your own. If they say yes, follow the contract: `npx synthesisui read` with the dev server up. Offer it ONCE: if their next message does not take it up, that is a no - never offer it again in later turns.";
|
|
862
|
+
/**
|
|
863
|
+
* A ÚLTIMA RESPOSTA DO AGENTE OFERECE A LEITURA? A última entrada `assistant` com texto, no registro. Nas quatro rodadas
|
|
864
|
+
* em que ele ofereceu, a frase tinha as duas coisas: ler a página (ou o site) e o sistema - "Want me to read the page and
|
|
865
|
+
* turn what it's already decided into your design system?". Na porta-7 (19/10) a oferta veio pelo nome do comando, sem
|
|
866
|
+
* "page": "I can run `npx synthesisui read` and ask you a few questions to lock in the design system".
|
|
867
|
+
*/
|
|
868
|
+
export async function lastReplyOffers(transcript) {
|
|
869
|
+
const raw = await readFile(transcript, "utf8").catch(() => null);
|
|
870
|
+
if (raw === null)
|
|
871
|
+
return false;
|
|
872
|
+
for (const line of raw.split("\n").reverse()) {
|
|
873
|
+
if (!/"type":\s*"assistant"/.test(line))
|
|
874
|
+
continue;
|
|
875
|
+
let e;
|
|
876
|
+
try {
|
|
877
|
+
e = JSON.parse(line);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const c = e.message?.content;
|
|
883
|
+
const text = Array.isArray(c)
|
|
884
|
+
? c.filter((x) => x?.type === "text").map((x) => x.text ?? "").join(" ")
|
|
885
|
+
: typeof c === "string" ? c : "";
|
|
886
|
+
if (!text.trim())
|
|
887
|
+
continue;
|
|
888
|
+
if (!/\bsystem\b/i.test(text))
|
|
889
|
+
return false;
|
|
890
|
+
return /\bread\b[^.?!]{0,60}\b(page|site|landing)\b/i.test(text) || /synthesisui read\b/i.test(text);
|
|
891
|
+
}
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* A PESSOA FALOU DEPOIS DE `at`? No registro, a fala dela é uma entrada `user` que não é meta, não é resultado de
|
|
896
|
+
* ferramenta, não é o retorno de um gancho e não é o aviso de um subagente - os três últimos também chegam como `user`.
|
|
897
|
+
* Sem o registro, conta como falou: na dúvida, não se insiste.
|
|
898
|
+
*/
|
|
899
|
+
export async function personSpokeAfter(transcript, at) {
|
|
900
|
+
const raw = await readFile(transcript, "utf8").catch(() => null);
|
|
901
|
+
if (raw === null)
|
|
902
|
+
return true;
|
|
903
|
+
for (const line of raw.split("\n").reverse()) {
|
|
904
|
+
if (!/"type":\s*"user"/.test(line))
|
|
905
|
+
continue;
|
|
906
|
+
let e;
|
|
907
|
+
try {
|
|
908
|
+
e = JSON.parse(line);
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (e.type !== "user" || e.isMeta || !e.timestamp)
|
|
914
|
+
continue;
|
|
915
|
+
if (!(Date.parse(e.timestamp) > at))
|
|
916
|
+
return false;
|
|
917
|
+
const c = e.message?.content;
|
|
918
|
+
const text = typeof c === "string"
|
|
919
|
+
? c
|
|
920
|
+
: Array.isArray(c) && !c.some((x) => x?.type === "tool_result")
|
|
921
|
+
? c.map((x) => x?.text ?? "").join(" ")
|
|
922
|
+
: null;
|
|
923
|
+
if (text === null)
|
|
924
|
+
continue;
|
|
925
|
+
if (/^\s*(<task-notification>|<command-|<system-reminder>|Stop hook feedback)/.test(text))
|
|
926
|
+
continue;
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
return false;
|
|
770
930
|
}
|
|
771
931
|
/**
|
|
772
932
|
* A ÚLTIMA MENSAGEM É O RESUMO, NÃO O RODAPÉ - bateria ampliada, item 19 (29/09). Este bloqueio faz o agente responder
|
package/dist/commands/init.js
CHANGED
|
@@ -2,10 +2,12 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { DEFAULT_CONFIG, resolveRegistry, writeProjectConfig, } from "../config.js";
|
|
4
4
|
import { readProjectTheme, writeBesideTheirs, writeNewProjectRoot, writeNewProjectTheme, } from "../new-project-theme.js";
|
|
5
|
+
import { openTheDoor } from "../door.js";
|
|
5
6
|
import { body, section, snippet } from "../output.js";
|
|
6
7
|
import { fetchDesignSystem } from "../registry.js";
|
|
7
8
|
import { resolveDeps, stylesFor, tailwindMajor } from "../stack.js";
|
|
8
9
|
import { formMix, measuredStyle } from "../styles-flavour.js";
|
|
10
|
+
import { desligarCaptura, ligarCaptura } from "../transcript.js";
|
|
9
11
|
import { add } from "./add.js";
|
|
10
12
|
const whereOf = (answer) => answer === "file" || answer === "1"
|
|
11
13
|
? "file"
|
|
@@ -57,6 +59,14 @@ export async function init(opts) {
|
|
|
57
59
|
*/
|
|
58
60
|
if (opts.ds)
|
|
59
61
|
await fetchDesignSystem(resolveRegistry(opts.registry), opts.ds);
|
|
62
|
+
/**
|
|
63
|
+
* SEM `--ds`, O `init` É A PORTA (fase 2, 18/10) - ver `door.ts`. O que ele imprimia sobre o config continua sendo
|
|
64
|
+
* calculado igual, só que guardado: a porta fecha com um resumo de cinco linhas, e o detalhe do config é o que menos
|
|
65
|
+
* importa para quem acabou de chegar.
|
|
66
|
+
*/
|
|
67
|
+
const door = !opts.ds;
|
|
68
|
+
if (door)
|
|
69
|
+
ligarCaptura();
|
|
60
70
|
const target = opts.target === "general" ? "general" : "next";
|
|
61
71
|
const config = {
|
|
62
72
|
target,
|
|
@@ -270,11 +280,14 @@ export async function init(opts) {
|
|
|
270
280
|
console.log(body("first clean file and goes quiet after that."));
|
|
271
281
|
return;
|
|
272
282
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
283
|
+
await openTheDoor({
|
|
284
|
+
root,
|
|
285
|
+
version: opts.cli ?? "latest",
|
|
286
|
+
...(opts.registry ? { registry: opts.registry } : {}),
|
|
287
|
+
...(opts.pkg ? { pkg: opts.pkg } : {}),
|
|
288
|
+
...(opts.aesthetic ? { aesthetic: opts.aesthetic } : {}),
|
|
289
|
+
configLines: desligarCaptura(),
|
|
290
|
+
});
|
|
278
291
|
}
|
|
279
292
|
/**
|
|
280
293
|
* O QUE O `init` ACABOU DE DECLARAR VAI PARA O `.lock` (jornada "o pedido livre sai mastigado", 15/10).
|
package/dist/commands/read.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { desligarCaptura, ligarCaptura } from "../transcript.js";
|
|
3
|
+
import { adopt } from "./adopt.js";
|
|
2
4
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
5
|
import { createRequire } from "node:module";
|
|
4
6
|
import { homedir } from "node:os";
|
|
@@ -6,7 +8,7 @@ import { join, relative } from "node:path";
|
|
|
6
8
|
import { classify, pixelRatio } from "../reading/classify.js";
|
|
7
9
|
import { PAGE_SCRIPT } from "../reading/page-script.js";
|
|
8
10
|
import { decodePng } from "../reading/png.js";
|
|
9
|
-
import { pxOf } from "../role-rules.js";
|
|
11
|
+
import { globalClassOf, globalStyles, moduleClassName, pxOf } from "../role-rules.js";
|
|
10
12
|
import { agentsToMaintain } from "../agents-chosen.js";
|
|
11
13
|
import { syncClaudeMd } from "../claude-md.js";
|
|
12
14
|
import { projectRootFrom } from "./hook.js";
|
|
@@ -112,12 +114,46 @@ function sources(root) {
|
|
|
112
114
|
return out;
|
|
113
115
|
}
|
|
114
116
|
/** Onde o texto mora no código: o componente (React) -> o arquivo que o declara -> a linha da classe; senão, pela classe. */
|
|
117
|
+
/**
|
|
118
|
+
* A CLASSE DE UM CSS MODULE NO DOM - `page-module___8aEwW__h2` (o Turbopack: arquivo, hash, nome local). Achado na rodada
|
|
119
|
+
* real da porta (18/10): sem isto, a página que o impeccable escreveu com `styles.h2` saía com 5 papéis lidos e 0
|
|
120
|
+
* gravados. A extensão vem do import que o código escreve.
|
|
121
|
+
*/
|
|
122
|
+
function moduleOf(cls, files) {
|
|
123
|
+
/* a do próprio elemento primeiro; sem ela, a do título e a do pai (a capa da porta-2: `<span>` sem classe dentro de
|
|
124
|
+
`<p className={styles.batch}>`, com o tamanho em `.batch span`) */
|
|
125
|
+
for (const tok of cls.split(" | ").flatMap((part) => part.split(/\s+/))) {
|
|
126
|
+
const m = tok.match(/^([A-Za-z0-9-]+?)-module__.+?__([A-Za-z0-9_-]+)$/);
|
|
127
|
+
if (!m)
|
|
128
|
+
continue;
|
|
129
|
+
const stem = m[1];
|
|
130
|
+
const local = m[2];
|
|
131
|
+
const imp = new RegExp(`["'][^"']*\\b${stem}\\.module\\.(s?css)["']`);
|
|
132
|
+
const ext = files.map((f) => readFileSync(f, "utf8").match(imp)?.[1]).find(Boolean) ?? "css";
|
|
133
|
+
return { name: moduleClassName(`${stem}.module.${ext}`, local), local };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
let stylesOf = null;
|
|
115
138
|
function homeOf(root, files, t) {
|
|
116
|
-
const
|
|
139
|
+
const sized = sizeClassOf(t.cls);
|
|
140
|
+
/* a classe global (o juízo dele, 19/10): `className="section-title"` com o tamanho no globals.css - a do elemento
|
|
141
|
+
primeiro, depois a do pai (o `<h3>` sem classe dentro de `.route`, medido em `.route h3`) */
|
|
142
|
+
if (!sized && stylesOf?.root !== root)
|
|
143
|
+
stylesOf = { root, styles: globalStyles(root) };
|
|
144
|
+
const glob = sized || moduleOf(t.cls, files) ? null
|
|
145
|
+
: globalClassOf(t.cls.split(" | ").flatMap((part) => part.split(/\s+/)), stylesOf?.styles ?? []);
|
|
146
|
+
const mod = sized ? null : (moduleOf(t.cls, files) ?? (glob ? { name: glob.name, local: glob.local } : null));
|
|
147
|
+
const cls = sized ?? mod?.name ?? null;
|
|
117
148
|
const lineOf = (f) => {
|
|
118
149
|
if (!cls)
|
|
119
150
|
return null;
|
|
120
|
-
const
|
|
151
|
+
const lines = readFileSync(f, "utf8").split("\n");
|
|
152
|
+
const i = mod
|
|
153
|
+
? lines.findIndex((l) => glob
|
|
154
|
+
? new RegExp(`className=[^>]*?(?<![\\w-])${mod.local.replace(/-/g, "\\-")}(?![\\w-])`).test(l)
|
|
155
|
+
: new RegExp(`\\b\\w+(?:\\.${mod.local}\\b|\\[["']${mod.local}["']\\])`).test(l))
|
|
156
|
+
: lines.findIndex((l) => l.includes(cls));
|
|
121
157
|
return i >= 0 ? i + 1 : null;
|
|
122
158
|
};
|
|
123
159
|
if (t.comp) {
|
|
@@ -126,7 +162,7 @@ function homeOf(root, files, t) {
|
|
|
126
162
|
if (f)
|
|
127
163
|
return { file: relative(root, f), line: lineOf(f), cls, via: "component", component: t.comp };
|
|
128
164
|
}
|
|
129
|
-
if (cls) {
|
|
165
|
+
if (cls && !mod) {
|
|
130
166
|
const f = files.find((x) => readFileSync(x, "utf8").includes(cls));
|
|
131
167
|
if (f)
|
|
132
168
|
return { file: relative(root, f), line: lineOf(f), cls, via: "class", component: t.comp };
|
|
@@ -194,9 +230,21 @@ export async function confirm(opts) {
|
|
|
194
230
|
const answers = opts.answers ? JSON.parse(readFileSync(opts.answers, "utf8")) : {};
|
|
195
231
|
const pick = (id) => answers[id] ?? 0;
|
|
196
232
|
const dsDir = join(root, "_synthesisui", "ds");
|
|
197
|
-
const
|
|
233
|
+
const firstSystem = () => existsSync(dsDir) ? readdirSync(dsDir).find((d) => statSync(join(dsDir, d)).isDirectory()) : undefined;
|
|
234
|
+
/* O SISTEMA NASCE AQUI quando o projeto chegou pela porta (fase 2, 18/10): o `init` não adota, porque num projeto vazio
|
|
235
|
+
os únicos tokens são os de exemplo do create-next-app. Agora a página já decidiu - os tokens que ela tem são os dela. */
|
|
236
|
+
if (!firstSystem()) {
|
|
237
|
+
ligarCaptura();
|
|
238
|
+
try {
|
|
239
|
+
await adopt({ dir: root, write: true });
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
desligarCaptura();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const slug = firstSystem();
|
|
198
246
|
if (!slug) {
|
|
199
|
-
say("
|
|
247
|
+
say("No tokens found in this project's CSS, so there is no system to write the answers into - check that the page's stylesheet declares them in :root or @theme.");
|
|
200
248
|
process.exitCode = 1;
|
|
201
249
|
return;
|
|
202
250
|
}
|
|
@@ -222,6 +270,10 @@ export async function confirm(opts) {
|
|
|
222
270
|
writeFileSync(join(dsDir, slug, "rules.json"), `${JSON.stringify(rules, null, 2)}\n`);
|
|
223
271
|
const contract = await syncClaudeMd(root, await agentsToMaintain(root));
|
|
224
272
|
say(`Your system: ${roles.length} roles and ${breaks.length} breaks, written to _synthesisui/ds/${slug}/rules.json.`);
|
|
273
|
+
/* NUNCA CALADO (o juízo dele, 19/10): um papel lido sem a classe que o identifica no código fica fora - e isso se diz */
|
|
274
|
+
const unplaced = R.roles.filter((r) => !r.home?.cls);
|
|
275
|
+
if (unplaced.length > 0)
|
|
276
|
+
say(`Left out: ${unplaced.map((r) => `${r.role.split(" ")[0]} ${r.px}px`).join(", ")} - the page shows ${unplaced.length === 1 ? "it" : "them"}, but no class in the code says where ${unplaced.length === 1 ? "it is" : "they are"}, so the check could not hold ${unplaced.length === 1 ? "it" : "them"}.`);
|
|
225
277
|
say(contract ? "The contract your agent reads now lists them - every heading is checked against its role at the end of each turn." : "Run `synthesisui connect` so your agent reads and checks it.");
|
|
226
278
|
}
|
|
227
279
|
export async function read(opts) {
|
package/dist/door.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { connect } from "./commands/connect.js";
|
|
6
|
+
import { MCP_TOOL_COUNT } from "./commands/mcp.js";
|
|
7
|
+
import { body, paint, section, snippet } from "./output.js";
|
|
8
|
+
import { SKILLS } from "./skills.js";
|
|
9
|
+
import { desligarCaptura, ligarCaptura } from "./transcript.js";
|
|
10
|
+
/**
|
|
11
|
+
* A CAMADA DE ESTÉTICA PADRÃO, na versão que a régua do conjunto provou (18/10: corte, linhas A-C, relógios). O impeccable
|
|
12
|
+
* muda rápido; subir este número pede a régua de novo (D10 do mapa), não só o publish dele.
|
|
13
|
+
*/
|
|
14
|
+
export const IMPECCABLE_VERSION = "4.6.0";
|
|
15
|
+
/** O impeccable que já está no projeto, pela skill que o instalador DELE escreve - nunca por um arquivo nosso. */
|
|
16
|
+
export function impeccableIn(root) {
|
|
17
|
+
const skill = join(root, ".claude", "skills", "impeccable", "SKILL.md");
|
|
18
|
+
if (!existsSync(skill))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const head = readFileSync(skill, "utf8").slice(0, 4000);
|
|
22
|
+
return /^version:\s*(\S+)/m.exec(head)?.[1] ?? "?";
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return "?";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Uma pergunta, o sim como padrão - e só num terminal de verdade. */
|
|
29
|
+
async function askAesthetic() {
|
|
30
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
31
|
+
return null;
|
|
32
|
+
const { createInterface } = await import("node:readline/promises");
|
|
33
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
try {
|
|
35
|
+
const answer = await rl.question(` Add the aesthetic layer - impeccable ${IMPECCABLE_VERSION}, by its own installer? It creates the first look; SynthesisUI keeps every screen after it consistent. [Y/n] `);
|
|
36
|
+
return !/^n/i.test(answer.trim());
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
rl.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* COMPOR, NUNCA FORKAR (decisão dele, 18/10 15:25): o SynthesisUI é o ecossistema e o impeccable é uma camada. Quem
|
|
47
|
+
* instala é o instalador DELE, chamado por nós; nenhum arquivo dele é escrito, copiado ou apagado aqui. Já instalado,
|
|
48
|
+
* fica como está.
|
|
49
|
+
*/
|
|
50
|
+
async function aestheticLayer(root, wanted) {
|
|
51
|
+
const found = impeccableIn(root);
|
|
52
|
+
if (found)
|
|
53
|
+
return { kind: "found", version: found };
|
|
54
|
+
if (wanted === "none")
|
|
55
|
+
return { kind: "declined", asked: false };
|
|
56
|
+
if (wanted !== "impeccable") {
|
|
57
|
+
const yes = await askAesthetic();
|
|
58
|
+
if (yes === false)
|
|
59
|
+
return { kind: "declined", asked: true };
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
execFileSync("npx", ["-y", `impeccable@${IMPECCABLE_VERSION}`, "install", "-y", "--providers=claude", "--project"], { cwd: root, stdio: "pipe" });
|
|
63
|
+
return { kind: "installed", version: impeccableIn(root) ?? IMPECCABLE_VERSION };
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const err = e;
|
|
67
|
+
return {
|
|
68
|
+
kind: "failed",
|
|
69
|
+
why: err.stderr?.toString().trim().split("\n").filter(Boolean).pop() ??
|
|
70
|
+
err.message ??
|
|
71
|
+
"unknown",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** O gerenciador que o projeto já usa, pelo lockfile - instalar com outro deixaria dois lockfiles brigando. */
|
|
76
|
+
export function packageManagerOf(root) {
|
|
77
|
+
if (existsSync(join(root, "pnpm-lock.yaml")))
|
|
78
|
+
return "pnpm";
|
|
79
|
+
if (existsSync(join(root, "yarn.lock")))
|
|
80
|
+
return "yarn";
|
|
81
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
|
|
82
|
+
return "bun";
|
|
83
|
+
return "npm";
|
|
84
|
+
}
|
|
85
|
+
export function addDevArgs(pm, spec) {
|
|
86
|
+
if (pm === "npm")
|
|
87
|
+
return ["install", "--save-dev", "--no-audit", "--no-fund", spec];
|
|
88
|
+
if (pm === "bun")
|
|
89
|
+
return ["add", "--dev", spec];
|
|
90
|
+
return ["add", "--save-dev", spec];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* O CLI COMO DEPENDÊNCIA DO PROJETO - é o que faz o fim do turno rodar a versão dele, e ~10x mais rápido que o `npx`
|
|
94
|
+
* (medido em 27/07, ver `hookCommand`). O `connect` escolhe sozinho o local quando ele está em `node_modules`, então
|
|
95
|
+
* esta instalação vem ANTES dele.
|
|
96
|
+
*
|
|
97
|
+
* `spec` existe para provar uma versão antes do publish (um tarball do `npm pack`); o padrão é a versão que roda.
|
|
98
|
+
*/
|
|
99
|
+
async function installLocalCli(root, spec) {
|
|
100
|
+
const pm = packageManagerOf(root);
|
|
101
|
+
try {
|
|
102
|
+
execFileSync(pm, addDevArgs(pm, spec), { cwd: root, stdio: "pipe" });
|
|
103
|
+
return { ok: true, pm };
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
const err = e;
|
|
107
|
+
const why = err.stderr?.toString().trim().split("\n").filter(Boolean).pop() ??
|
|
108
|
+
err.message ??
|
|
109
|
+
"unknown";
|
|
110
|
+
return { ok: false, pm, why };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function openTheDoor(opts) {
|
|
114
|
+
const { root, version } = opts;
|
|
115
|
+
const pkgPath = join(root, "package.json");
|
|
116
|
+
const hasPackage = existsSync(pkgPath);
|
|
117
|
+
const local = hasPackage
|
|
118
|
+
? await installLocalCli(root, opts.pkg ?? `synthesisui@${version}`)
|
|
119
|
+
: null;
|
|
120
|
+
const layer = await aestheticLayer(root, opts.aesthetic);
|
|
121
|
+
ligarCaptura();
|
|
122
|
+
let failed = null;
|
|
123
|
+
try {
|
|
124
|
+
await connect({
|
|
125
|
+
dir: root,
|
|
126
|
+
version,
|
|
127
|
+
...(opts.registry ? { registry: opts.registry } : {}),
|
|
128
|
+
hook: true,
|
|
129
|
+
mcp: true,
|
|
130
|
+
agents: "claude",
|
|
131
|
+
interactive: false,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
failed = e;
|
|
136
|
+
}
|
|
137
|
+
const connectLines = desligarCaptura();
|
|
138
|
+
if (failed) {
|
|
139
|
+
for (const line of [...opts.configLines, ...connectLines])
|
|
140
|
+
console.log(line);
|
|
141
|
+
throw failed;
|
|
142
|
+
}
|
|
143
|
+
const installed = hasPackage
|
|
144
|
+
? (JSON.parse(await readFile(pkgPath, "utf8")).devDependencies?.synthesisui ?? null)
|
|
145
|
+
: null;
|
|
146
|
+
console.log(section("SynthesisUI is set up"));
|
|
147
|
+
console.log("");
|
|
148
|
+
console.log(body(local?.ok
|
|
149
|
+
? `✓ the CLI synthesisui ${version} in your devDependencies (${local.pm}) - the check runs this one`
|
|
150
|
+
: local
|
|
151
|
+
? `· the CLI not installed (${local.why}) - the check runs it through npx, slower`
|
|
152
|
+
: "· the CLI no package.json here - the check runs it through npx, slower"));
|
|
153
|
+
console.log(body(`✓ your agent Claude Code: the check at the end of each turn, ${MCP_TOOL_COUNT} tools, ${SKILLS.length} skills`));
|
|
154
|
+
console.log(body(layer.kind === "found"
|
|
155
|
+
? `✓ aesthetic impeccable ${layer.version} was already here - left exactly as it is`
|
|
156
|
+
: layer.kind === "installed"
|
|
157
|
+
? `✓ aesthetic impeccable ${layer.version}, by its own installer - it creates the first look`
|
|
158
|
+
: layer.kind === "failed"
|
|
159
|
+
? `· aesthetic impeccable did not install (${layer.why}) - run init again to retry`
|
|
160
|
+
: "· aesthetic none - the first look comes from your agent alone, with no design direction behind it"));
|
|
161
|
+
if (layer.kind === "declined")
|
|
162
|
+
console.log(body(paint.faint(" add it later: npx synthesisui init --aesthetic impeccable")));
|
|
163
|
+
if (layer.kind === "installed" && opts.aesthetic !== "impeccable" && !process.stdin.isTTY)
|
|
164
|
+
console.log(body(paint.faint(" installed by default - `--aesthetic none` leaves it out")));
|
|
165
|
+
console.log(body("✓ no account nothing here needs one"));
|
|
166
|
+
if (installed && local?.ok === false)
|
|
167
|
+
console.log(body(paint.faint(` (package.json already lists synthesisui ${installed})`)));
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(section("Next"));
|
|
170
|
+
console.log("");
|
|
171
|
+
console.log(body("Open your agent here and ask for your first screen:"));
|
|
172
|
+
console.log("");
|
|
173
|
+
console.log(snippet(["claude", '"make a landing page for <what you are building>"']));
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(body("When the first screen is done, the agent offers to read it and turn what it decided into your system."));
|
|
176
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -50,7 +50,8 @@ const HELP = `synthesisui - bring SynthesisUI design systems into your project
|
|
|
50
50
|
Usage - deterministic, FREE:
|
|
51
51
|
synthesisui login [options] connect the CLI to your account (device-flow; --force to switch accounts)
|
|
52
52
|
synthesisui logout sign out of this machine (removes the saved token)
|
|
53
|
-
synthesisui init [options]
|
|
53
|
+
synthesisui init [options] start here: the CLI in your devDependencies, your agent wired, no account;
|
|
54
|
+
--ds <slug> brings a published system in instead
|
|
54
55
|
synthesisui list [options] list the published design systems
|
|
55
56
|
synthesisui list --mine your own systems, with their group
|
|
56
57
|
synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
|
|
@@ -125,6 +126,8 @@ Options:
|
|
|
125
126
|
--dir <path> consumer project root (default: current directory)
|
|
126
127
|
--version <n> install a specific version (default: latest)
|
|
127
128
|
--ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
|
|
129
|
+
--package <spec> init: what to add to devDependencies (default: synthesisui@<this version>)
|
|
130
|
+
--aesthetic <a> init: impeccable | none - the layer that creates the first look (default: impeccable, asked in a terminal)
|
|
128
131
|
--name <name> import: the NAME of the system, which is what its install slug
|
|
129
132
|
comes from - and the slug never changes. Also the preferred
|
|
130
133
|
component name for generate.
|
|
@@ -165,7 +168,7 @@ Examples:
|
|
|
165
168
|
synthesisui adopt # you already have a system: start here
|
|
166
169
|
synthesisui import --dry # see the census before anything is sent
|
|
167
170
|
synthesisui login
|
|
168
|
-
synthesisui init
|
|
171
|
+
synthesisui init # an empty project: one command, then ask your agent
|
|
169
172
|
synthesisui init --target next --ds halogen bootstrap + bring a system in
|
|
170
173
|
synthesisui doctor
|
|
171
174
|
synthesisui doctor apps/web packages/ui # scope the read in a monorepo
|
|
@@ -574,6 +577,8 @@ async function main() {
|
|
|
574
577
|
...(typeof flags["theme-in"] === "string"
|
|
575
578
|
? { themeIn: flags["theme-in"] }
|
|
576
579
|
: {}),
|
|
580
|
+
...(typeof flags.package === "string" ? { pkg: flags.package } : {}),
|
|
581
|
+
...(typeof flags.aesthetic === "string" ? { aesthetic: flags.aesthetic } : {}),
|
|
577
582
|
cli: CLI_VERSION,
|
|
578
583
|
});
|
|
579
584
|
break;
|
package/dist/install-marks.js
CHANGED
|
@@ -350,7 +350,12 @@
|
|
|
350
350
|
* sem eles, a linha que ensina o agente a oferecer a leitura (`synthesisui read`) e a confirmar (`read --confirm`). Uma
|
|
351
351
|
* pasta escrita antes não fala de papéis nem da leitura.
|
|
352
352
|
*/
|
|
353
|
-
|
|
353
|
+
/**
|
|
354
|
+
* 0.16.500 -> 0.16.501 em 18/10, e o passo 1 dá **SIM** (jornada "um instalador, uma porta"): num projeto sem sistema, o
|
|
355
|
+
* bloco do `CLAUDE.md` ensina a leitura da primeira tela em vez do `/sui-import-ds`; com o `rules.json` e o impeccable no
|
|
356
|
+
* projeto, diz que o revisor dele é só da criação. Uma pasta escrita antes manda o agente importar e não fala do revisor.
|
|
357
|
+
*/
|
|
358
|
+
export const MATERIALISER_SINCE = "0.16.501";
|
|
354
359
|
/**
|
|
355
360
|
* A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
|
|
356
361
|
*
|
|
@@ -574,7 +579,13 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
|
|
|
574
579
|
* vez) e acha a raiz pelo projeto - não pela pasta onde o terminal do agente parou, que deixava turnos inteiros sem
|
|
575
580
|
* conferência. Um hook pinado antes não confere papel nenhum.
|
|
576
581
|
*/
|
|
577
|
-
|
|
582
|
+
/**
|
|
583
|
+
* 0.16.500 -> 0.16.501 em 18/10, e o passo 1 dá **SIM** (jornada "um instalador, uma porta"): o fim do turno pede ao agente,
|
|
584
|
+
* uma vez, que ofereça a leitura quando a primeira tela aparece num projeto sem sistema nenhum. Um hook pinado antes nunca
|
|
585
|
+
* oferece. E a checagem por papel lê CSS Modules (`className={styles.h2}`, o tamanho no `.module.css`): um hook anterior
|
|
586
|
+
* não confere papel nenhum numa página escrita assim.
|
|
587
|
+
*/
|
|
588
|
+
export const CHECKER_SINCE = "0.16.501";
|
|
578
589
|
/**
|
|
579
590
|
* A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
|
|
580
591
|
*
|
package/dist/role-rules.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
1
2
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
3
4
|
export async function loadRoleRules(root) {
|
|
4
5
|
const ds = join(root, "_synthesisui", "ds");
|
|
5
6
|
const slugs = await readdir(ds).catch(() => []);
|
|
@@ -44,6 +45,116 @@ function sizeClass(attrs) {
|
|
|
44
45
|
return null;
|
|
45
46
|
return found.sort((a, b) => (pxOf(b) ?? 0) - (pxOf(a) ?? 0))[0] ?? null;
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A CLASSE DE UM CSS MODULE - achado na rodada real da porta (18/10, `regua/rodadas/porta-2`): o impeccable escreveu a
|
|
50
|
+
* landing com `className={styles.h2}` (117 usos, 4 `.module.css`), a leitura achou 5 papéis e o `--confirm` gravou ZERO,
|
|
51
|
+
* porque a identidade de um papel era só a classe de tamanho do Tailwind. Uma de três criações medidas foi assim.
|
|
52
|
+
*
|
|
53
|
+
* A identidade passa a ser o ARQUIVO e o nome local - `page.module.css .h2` -, que é o que a pessoa acha no código e o
|
|
54
|
+
* que o hash do build não muda. O apelido do import (`styles`, `s`) não entra: dois arquivos podem chamar o mesmo módulo
|
|
55
|
+
* de jeitos diferentes.
|
|
56
|
+
*/
|
|
57
|
+
export function moduleClassName(file, local) {
|
|
58
|
+
return `${file} .${local}`;
|
|
59
|
+
}
|
|
60
|
+
/** Os imports de CSS Module de um arquivo: apelido -> o caminho como foi escrito. */
|
|
61
|
+
function moduleImports(src) {
|
|
62
|
+
const out = new Map();
|
|
63
|
+
for (const m of src.matchAll(/import\s+(\w+)\s+from\s+["']([^"']+\.module\.s?css)["']/g))
|
|
64
|
+
out.set(m[1], m[2]);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** As classes de módulo dentro de um `className={...}`, na ordem em que aparecem. */
|
|
68
|
+
function moduleClasses(attrs, imports) {
|
|
69
|
+
const expr = attrs.match(/className=\{([^}]*(?:\}[^}]*)?)\}/)?.[1] ?? "";
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const m of expr.matchAll(/\b(\w+)(?:\.([A-Za-z_][\w-]*)|\[["']([\w-]+)["']\])/g)) {
|
|
72
|
+
const path = imports.get(m[1]);
|
|
73
|
+
const local = (m[2] ?? m[3]);
|
|
74
|
+
if (path && local)
|
|
75
|
+
out.push({ name: moduleClassName(basename(path), local), path, local });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** As classes escritas como texto em `className="a b"` (ou num template sem expressão no meio do nome). */
|
|
80
|
+
function plainClasses(attrs) {
|
|
81
|
+
const lit = attrs.match(/className=\{?\s*["'`]([^"'`]*)["'`]/)?.[1] ?? "";
|
|
82
|
+
return lit.split(/\s+/).filter((t) => /^[A-Za-z_][\w-]*$/.test(t));
|
|
83
|
+
}
|
|
84
|
+
/** O `font-size` de uma classe dentro do `.module.css` - o maior de um `clamp()` (o desktop), `rem` a 16px. */
|
|
85
|
+
export function modulePx(css, local) {
|
|
86
|
+
let best = null;
|
|
87
|
+
const esc = local.replace(/[-]/g, "\\-");
|
|
88
|
+
for (const m of css.matchAll(new RegExp(`([^{}]*\\.${esc}(?![\\w-])[^{}]*)\\{([^}]*)\\}`, "g"))) {
|
|
89
|
+
const v = m[2].match(/font-size\s*:\s*([^;]+)/)?.[1]?.trim();
|
|
90
|
+
if (!v)
|
|
91
|
+
continue;
|
|
92
|
+
const parts = v.startsWith("clamp(") ? v.slice(6, -1).split(",") : [v];
|
|
93
|
+
for (const p of parts) {
|
|
94
|
+
const n = p.trim().match(/^([\d.]+)(px|rem)$/);
|
|
95
|
+
if (!n)
|
|
96
|
+
continue;
|
|
97
|
+
const px = Number(n[1]) * (n[2] === "rem" ? 16 : 1);
|
|
98
|
+
if (best === null || px > best)
|
|
99
|
+
best = px;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A CLASSE GLOBAL - achado no juízo dele (19/10, a landing da Naiker, `synthesisui-tests/juizo-porta`): o impeccable
|
|
106
|
+
* escreveu os títulos com classes comuns, `className="section-title"`, e o tamanho no `globals.css`. A leitura achou os
|
|
107
|
+
* papéis (h1 180, h2 70,4, h3 28,8) e o `--confirm` gravou ZERO, calado - o mesmo defeito do CSS Modules (porta-2) numa
|
|
108
|
+
* terceira forma de escrever estilo.
|
|
109
|
+
*
|
|
110
|
+
* A identidade é a mesma forma do módulo: o arquivo e a classe, `globals.css .section-title`. Vale a classe que tem um
|
|
111
|
+
* `font-size` numa folha de estilo comum do projeto (não `.module.css`), na ordem em que vem - a do próprio elemento
|
|
112
|
+
* primeiro.
|
|
113
|
+
*/
|
|
114
|
+
export function globalStyles(root) {
|
|
115
|
+
const out = [];
|
|
116
|
+
const walk = (d) => {
|
|
117
|
+
let names = [];
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(d);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const n of names) {
|
|
125
|
+
if (n === "node_modules" || n.startsWith(".") || n === "_synthesisui" || n === "public")
|
|
126
|
+
continue;
|
|
127
|
+
const p = join(d, n);
|
|
128
|
+
let dir = false;
|
|
129
|
+
try {
|
|
130
|
+
dir = statSync(p).isDirectory();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (dir)
|
|
136
|
+
walk(p);
|
|
137
|
+
else if (/\.s?css$/.test(n) && !/\.module\.s?css$/.test(n))
|
|
138
|
+
out.push({ file: n, css: readFileSync(p, "utf8") });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
walk(existsSync(join(root, "src")) ? join(root, "src") : root);
|
|
142
|
+
if (existsSync(join(root, "app")) && existsSync(join(root, "src")))
|
|
143
|
+
walk(join(root, "app"));
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
export function globalClassOf(tokens, styles) {
|
|
147
|
+
for (const tok of tokens) {
|
|
148
|
+
if (!/^[A-Za-z_][\w-]*$/.test(tok))
|
|
149
|
+
continue;
|
|
150
|
+
for (const st of styles) {
|
|
151
|
+
const px = modulePx(st.css, tok);
|
|
152
|
+
if (px !== null)
|
|
153
|
+
return { name: moduleClassName(st.file, tok), local: tok, px };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
47
158
|
/** Dentro de um `.map(` ainda aberto = o título se repete = é de cartão. A mesma pergunta que a régua faz ao DOM. */
|
|
48
159
|
function insideMap(src, at) {
|
|
49
160
|
const before = src.slice(0, at);
|
|
@@ -56,16 +167,31 @@ function insideMap(src, at) {
|
|
|
56
167
|
return depth > 0;
|
|
57
168
|
}
|
|
58
169
|
const near = (a, b) => Math.abs(a - b) / Math.max(a, b) < 0.03;
|
|
59
|
-
export function checkRoles(rel, src, rules) {
|
|
170
|
+
export function checkRoles(rel, src, rules, root) {
|
|
60
171
|
if (rules.enforce === false || !/\.(tsx|jsx)$/.test(rel))
|
|
61
172
|
return [];
|
|
173
|
+
const imports = moduleImports(src);
|
|
174
|
+
const cssOf = new Map();
|
|
175
|
+
const pxOfModule = (path, local) => {
|
|
176
|
+
if (!root)
|
|
177
|
+
return null;
|
|
178
|
+
const file = join(root, dirname(rel), path);
|
|
179
|
+
if (!cssOf.has(file))
|
|
180
|
+
cssOf.set(file, existsSync(file) ? readFileSync(file, "utf8") : "");
|
|
181
|
+
return modulePx(cssOf.get(file), local);
|
|
182
|
+
};
|
|
183
|
+
/** As folhas de estilo comuns do projeto, lidas uma vez e só se algum título usar classe sem tamanho do Tailwind. */
|
|
184
|
+
let globals = null;
|
|
185
|
+
const styles = () => (globals ??= root ? globalStyles(root) : []);
|
|
62
186
|
const component = basename(rel, extname(rel));
|
|
63
187
|
const known = new Set((rules.knownDrift ?? []).filter((d) => d.at.startsWith(rel)).map((d) => d.class));
|
|
64
188
|
const section = rules.roles.find((r) => r.element === "h2" && r.context === "section");
|
|
65
189
|
const out = [];
|
|
66
190
|
for (const m of src.matchAll(/<(h1|h2|h3)\b([^>]*)>/g)) {
|
|
67
191
|
const el = m[1];
|
|
68
|
-
const
|
|
192
|
+
const mod = sizeClass(m[2]) ? null : (moduleClasses(m[2], imports)[0] ?? null);
|
|
193
|
+
const glob = sizeClass(m[2]) || mod ? null : globalClassOf(plainClasses(m[2]), styles());
|
|
194
|
+
const cls = sizeClass(m[2]) ?? mod?.name ?? glob?.name ?? null;
|
|
69
195
|
if (!cls)
|
|
70
196
|
continue;
|
|
71
197
|
const line = src.slice(0, m.index).split("\n").length;
|
|
@@ -78,7 +204,7 @@ export function checkRoles(rel, src, rules) {
|
|
|
78
204
|
continue;
|
|
79
205
|
if (known.has(bare))
|
|
80
206
|
continue;
|
|
81
|
-
const px = pxOf(bare);
|
|
207
|
+
const px = mod ? pxOfModule(mod.path, mod.local) : glob ? glob.px : pxOf(bare);
|
|
82
208
|
const role = mine.find((r) => r.context === context) ?? mine[0];
|
|
83
209
|
const brk = rules.breaks.find((b) => b.class === bare);
|
|
84
210
|
if (brk) {
|
|
@@ -111,6 +237,8 @@ export function checkRoles(rel, src, rules) {
|
|
|
111
237
|
* quebra fora do componente dela. A cópia com OUTRO tamanho (41,6 no lugar de 57,6) não aparece no código - é a régua
|
|
112
238
|
* da página renderizada que vê ("o mesmo motivo"), no fecho do turno.
|
|
113
239
|
*/
|
|
240
|
+
/* duas quebras podem morar na mesma classe (a capa da porta-2: "Batch" e "Nº 01" em `.batch span`) - uma linha basta */
|
|
241
|
+
const said = new Set();
|
|
114
242
|
for (const b of rules.breaks) {
|
|
115
243
|
if (!b.class || b.allowedIn === component)
|
|
116
244
|
continue;
|
|
@@ -118,9 +246,14 @@ export function checkRoles(rel, src, rules) {
|
|
|
118
246
|
if (/^h[1-3]$/.test(m[1]))
|
|
119
247
|
continue;
|
|
120
248
|
const cls = m[2].match(/className=\{?\s*["'`]([^"'`]*)["'`]/)?.[1] ?? "";
|
|
121
|
-
|
|
249
|
+
const mods = moduleClasses(m[2], imports).map((c) => c.name);
|
|
250
|
+
const globs = b.class.includes(" .") && !b.class.includes(".module.") ? cls.split(/\s+/).filter(Boolean).map((t) => globalClassOf([t], styles())?.name) : [];
|
|
251
|
+
if (!` ${cls} `.includes(` ${b.class} `) && !mods.includes(b.class) && !globs.includes(b.class))
|
|
122
252
|
continue;
|
|
123
253
|
const line = src.slice(0, m.index).split("\n").length;
|
|
254
|
+
if (said.has(`${line} ${b.class}`))
|
|
255
|
+
continue;
|
|
256
|
+
said.add(`${line} ${b.class}`);
|
|
124
257
|
out.push(` line ${line} <${m[1]}> ${b.class} is the break "${b.rule}" the person chose for ${b.allowedIn} (${b.home}). Reuse that component here instead of copying the motif, or ask whether this page gets its own.`);
|
|
125
258
|
}
|
|
126
259
|
}
|
package/package.json
CHANGED