synthesisui 0.16.153 → 0.16.154
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/absorb-plan.js +154 -0
- package/dist/commands/absorb.js +211 -0
- package/dist/commands/doctor.js +117 -14
- package/dist/commands/init.js +14 -0
- package/dist/commands/summary.js +79 -0
- package/dist/config.js +54 -0
- package/dist/doctor/frozen.js +13 -1
- package/dist/index.js +71 -0
- package/dist/measured-scope.js +63 -0
- package/dist/skill-init.js +92 -33
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { deltaE, JND } from "./doctor/color-distance.js";
|
|
2
|
+
import { nearestToken } from "./doctor/tokens.js";
|
|
3
|
+
/** Onde cada tipo de valor mora na fundação. `color` é o único com dois segmentos. */
|
|
4
|
+
const HOME = {
|
|
5
|
+
color: "color",
|
|
6
|
+
radius: "radius",
|
|
7
|
+
spacing: "spacing",
|
|
8
|
+
font: "typography",
|
|
9
|
+
motion: "motion.durations",
|
|
10
|
+
};
|
|
11
|
+
/** Uma palavra de categoria no começo do nome deles não é família - é a categoria repetida. */
|
|
12
|
+
const CATEGORY = /^(color|colour|radius|rounded|spacing|space|gap|font|type|duration|motion|ease|easing)-/;
|
|
13
|
+
const clean = (name) => name.replace(/^--/, "").toLowerCase();
|
|
14
|
+
/**
|
|
15
|
+
* O CAMINHO A PARTIR DO NOME DELES, e nada além dele.
|
|
16
|
+
*
|
|
17
|
+
* Cor precisa de família E step, e a forma que o mundo real produz é `<família>-<número>` - a mesma que
|
|
18
|
+
* o censo lê para montar as rampas deles. Sem o número não há step, e inventar um (`.500`) seria
|
|
19
|
+
* escolher uma posição na escala por eles: aí o valor volta sem caminho e a pessoa decide.
|
|
20
|
+
*
|
|
21
|
+
* O namespace é PRESERVADO de propósito: um repo com `--color-lightgray-300` e
|
|
22
|
+
* `--dashboard-lightgray-300` tem duas famílias, e `dashboard-lightgray` é o nome honesto da segunda.
|
|
23
|
+
*/
|
|
24
|
+
export function pathFor(kind, theirName) {
|
|
25
|
+
if (!theirName)
|
|
26
|
+
return "";
|
|
27
|
+
const bare = clean(theirName).replace(CATEGORY, "");
|
|
28
|
+
if (!bare)
|
|
29
|
+
return "";
|
|
30
|
+
if (kind === "color") {
|
|
31
|
+
const m = /^(.*[a-z0-9])-(\d{2,4})$/.exec(bare);
|
|
32
|
+
return m ? `color.${m[1]}.${m[2]}` : "";
|
|
33
|
+
}
|
|
34
|
+
return `${HOME[kind]}.${bare}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* O TOKEN DELES MAIS PRÓXIMO DESTE VALOR, quando existe um perto o bastante para valer a frase.
|
|
38
|
+
*
|
|
39
|
+
* Cor por ΔE76 (2.3 é a diferença que um olho começa a notar; abaixo disso são a mesma cor discutindo).
|
|
40
|
+
* Comprimento pelo mesmo `nearestToken` que o doctor já usa para dizer "snap to". Motion e tipografia
|
|
41
|
+
* ficam de fora: 0.2s e 0.25s não são "quase iguais", são durações diferentes de propósito.
|
|
42
|
+
*/
|
|
43
|
+
function nearestOwn(theirs, literal, kind) {
|
|
44
|
+
if (kind === "color") {
|
|
45
|
+
let best = null;
|
|
46
|
+
for (const [name, value] of theirs.byName) {
|
|
47
|
+
const d = deltaE(literal, value);
|
|
48
|
+
if (d === null || d === 0)
|
|
49
|
+
continue;
|
|
50
|
+
if (best === null || d < best.away)
|
|
51
|
+
best = { name, value, away: d };
|
|
52
|
+
}
|
|
53
|
+
/** Três ΔE: perto o bastante para alguém querer olhar, longe o bastante para não ser óbvio. */
|
|
54
|
+
if (!best || best.away > JND * 1.3)
|
|
55
|
+
return undefined;
|
|
56
|
+
return {
|
|
57
|
+
name: clean(best.name),
|
|
58
|
+
value: best.value,
|
|
59
|
+
away: `${best.away.toFixed(1)} ΔE`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (kind === "radius" || kind === "spacing") {
|
|
63
|
+
const hit = nearestToken(theirs, literal);
|
|
64
|
+
return hit
|
|
65
|
+
? {
|
|
66
|
+
name: clean(hit.name),
|
|
67
|
+
value: hit.value,
|
|
68
|
+
away: `${hit.delta}${/rem$/.test(literal) ? "rem" : "px"} apart`,
|
|
69
|
+
}
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A PROPOSTA: os valores mais repetidos que o sistema não nomeia, com o caminho quando ele é derivável.
|
|
76
|
+
*
|
|
77
|
+
* `theirs` é a tabela do vocabulário DELES (`buildTable({ source: "yours" })`), lida da raiz mesmo
|
|
78
|
+
* quando a medição é escopada - é o mesmo princípio que já vale no doctor: o vocabulário vem da raiz, só
|
|
79
|
+
* a leitura é escopada.
|
|
80
|
+
*
|
|
81
|
+
* `have` são os caminhos que a fundação já tem, para a proposta nunca sugerir sobrescrever um token
|
|
82
|
+
* existente com outro valor - isso não é absorver, é repintar.
|
|
83
|
+
*/
|
|
84
|
+
export function absorbPlan(d, theirs, have, cap = 40) {
|
|
85
|
+
const unnamed = d.repeats.filter((r) => !r.token);
|
|
86
|
+
const entries = [];
|
|
87
|
+
for (const r of unnamed) {
|
|
88
|
+
if (entries.length >= cap)
|
|
89
|
+
break;
|
|
90
|
+
const theirNames = theirs.byValue.get(r.literal.toLowerCase()) ?? [];
|
|
91
|
+
const theirName = theirNames[0];
|
|
92
|
+
const path = pathFor(r.kind, theirName);
|
|
93
|
+
const near = nearestOwn(theirs, r.literal, r.kind);
|
|
94
|
+
/** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
|
|
95
|
+
if (path && have.has(path))
|
|
96
|
+
continue;
|
|
97
|
+
entries.push({
|
|
98
|
+
value: r.literal,
|
|
99
|
+
kind: r.kind,
|
|
100
|
+
files: r.files,
|
|
101
|
+
count: r.count,
|
|
102
|
+
path,
|
|
103
|
+
...(theirName ? { theirName: clean(theirName) } : {}),
|
|
104
|
+
...(near ? { near } : {}),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { entries, more: Math.max(0, unnamed.length - entries.length) };
|
|
108
|
+
}
|
|
109
|
+
/** Quantas linhas da proposta ainda precisam de um nome humano. */
|
|
110
|
+
export const needingName = (plan) => plan.entries.filter((e) => !e.path);
|
|
111
|
+
/**
|
|
112
|
+
* A proposta em texto - e ela lidera pelo que está PRONTO para enviar.
|
|
113
|
+
*
|
|
114
|
+
* Uma lista que abre pelas pendências faz parecer que nada pode ser feito hoje; a maioria das linhas de
|
|
115
|
+
* um repo em migração já tem nome no vocabulário do próprio cliente, e essas viajam sem ninguém digitar
|
|
116
|
+
* nada.
|
|
117
|
+
*/
|
|
118
|
+
export function describeAbsorb(plan) {
|
|
119
|
+
const ready = plan.entries.filter((e) => e.path);
|
|
120
|
+
const pending = needingName(plan);
|
|
121
|
+
const lines = [];
|
|
122
|
+
if (plan.entries.length === 0)
|
|
123
|
+
return [
|
|
124
|
+
"Nothing to absorb: every repeated value your code writes by hand already has a name in the system.",
|
|
125
|
+
];
|
|
126
|
+
lines.push(`${ready.length} of ${plan.entries.length} values already have a name in YOUR vocabulary - those travel as they are:`);
|
|
127
|
+
for (const e of ready.slice(0, 8))
|
|
128
|
+
lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.files} file${e.files === 1 ? "" : "s"}${e.theirName ? ` · your ${e.theirName}` : ""}`);
|
|
129
|
+
if (ready.length > 8)
|
|
130
|
+
lines.push(` (${ready.length - 8} more)`);
|
|
131
|
+
if (pending.length > 0) {
|
|
132
|
+
const close = pending.filter((e) => e.near);
|
|
133
|
+
lines.push("");
|
|
134
|
+
lines.push(`${pending.length} nobody names yet. Naming is a design decision, so those wait for a word from you:`);
|
|
135
|
+
for (const e of pending.slice(0, 8))
|
|
136
|
+
lines.push(` ${e.value.padEnd(24)} ${`<${e.kind}, ${e.files} file${e.files === 1 ? "" : "s"}>`.padEnd(28)} ${e.near ? `≈ your ${e.near.name} (${e.near.away})` : 'path: ""'}`);
|
|
137
|
+
if (pending.length > 8)
|
|
138
|
+
lines.push(` (${pending.length - 8} more)`);
|
|
139
|
+
/**
|
|
140
|
+
* E A LEITURA QUE MUDA O TRABALHO: quando a maioria dos sem-nome está perto de um token que eles já
|
|
141
|
+
* têm, isto não é uma fila de batismos - é uma fila de NORMALIZAÇÃO, e sai muito mais barato.
|
|
142
|
+
*/
|
|
143
|
+
if (close.length > 0) {
|
|
144
|
+
lines.push("");
|
|
145
|
+
lines.push(`${close.length} of those ${close.length === 1 ? "is" : "are"} within a hair of a token you already have (marked ≈). Those are probably not new names - they are the same decision written twice, and pointing the code at what you have is cheaper than naming them.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** O teto NUNCA é silencioso: um corte que não se diz lê como "é tudo o que existe". */
|
|
149
|
+
if (plan.more > 0) {
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push(`${plan.more} more unnamed values are below the cut - the file holds the ${plan.entries.length} most repeated. Absorb these, re-run, and the next batch appears.`);
|
|
152
|
+
}
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { absorbPlan, describeAbsorb, needingName, } from "../absorb-plan.js";
|
|
4
|
+
import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
|
|
5
|
+
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
6
|
+
import { buildTable } from "../doctor/tokens.js";
|
|
7
|
+
import { measuredScope, scopePaths } from "../measured-scope.js";
|
|
8
|
+
import { body, paint, section, snippet } from "../output.js";
|
|
9
|
+
import { loadSystem, walkAll } from "./doctor.js";
|
|
10
|
+
/**
|
|
11
|
+
* `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
|
|
12
|
+
*
|
|
13
|
+
* O outro sentido da esteira, e o que faltava para o ciclo de migração fechar (dono, 06/08, movendo
|
|
14
|
+
* design de três apps para `packages/ui`):
|
|
15
|
+
*
|
|
16
|
+
* doctor --migrate 4223 valores que o sistema não nomeia, por frequência
|
|
17
|
+
* absorb ESTE: eles viram tokens do sistema
|
|
18
|
+
* upgrade o repo pega a versão nova
|
|
19
|
+
* doctor --fix agora eles têm nome, e a troca é automática
|
|
20
|
+
*
|
|
21
|
+
* DUAS FASES, e a do meio é humana de propósito. A primeira escreve uma PROPOSTA em
|
|
22
|
+
* `_synthesisui/absorb.json`: cada valor com o caminho derivado do nome que eles já usam, e vazio
|
|
23
|
+
* quando ninguém nomeia. A pessoa preenche os vazios num arquivo, de uma vez - vinte perguntas no
|
|
24
|
+
* terminal é como uma migração vira uma tarde perdida. Aí `--send` manda o lote.
|
|
25
|
+
*
|
|
26
|
+
* O arquivo é o contrato entre as duas fases, e é revisável por outra pessoa antes de ir: um lote de
|
|
27
|
+
* tokens novos num sistema compartilhado é exatamente o tipo de mudança que um time quer ver num diff.
|
|
28
|
+
*/
|
|
29
|
+
const FILE = "_synthesisui/absorb.json";
|
|
30
|
+
export async function absorb(opts) {
|
|
31
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
32
|
+
const path = join(root, FILE);
|
|
33
|
+
if (opts.send)
|
|
34
|
+
return sendProposal(root, path, opts.registry);
|
|
35
|
+
const installed = await loadSystem(root);
|
|
36
|
+
if (!installed.table.slug) {
|
|
37
|
+
console.log(section("Absorb"));
|
|
38
|
+
console.log(body("No system installed here, so there is nothing to absorb INTO. `synthesisui list` shows what you can add."));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const measured = await measuredScope(root);
|
|
42
|
+
const rel = scopePaths(measured);
|
|
43
|
+
const roots = rel.length > 0 ? rel.map((s) => resolve(root, s)) : [root];
|
|
44
|
+
/**
|
|
45
|
+
* DUAS TABELAS, e é a diferença entre elas que define o trabalho.
|
|
46
|
+
*
|
|
47
|
+
* A nossa diz o que o sistema JÁ nomeia - o que ela nomeia é assunto do `--fix`. A DELES diz como
|
|
48
|
+
* eles chamam o valor, e é de lá que o nome sai. Lida da RAIZ mesmo quando a medição é escopada: o
|
|
49
|
+
* vocabulário é do projeto, e um app que pega um token emprestado do `packages/ui` deles não o
|
|
50
|
+
* redeclara.
|
|
51
|
+
*/
|
|
52
|
+
let css = "";
|
|
53
|
+
for await (const file of walkAll([root]))
|
|
54
|
+
if (/\.(css|scss|sass|less)$/i.test(file))
|
|
55
|
+
css += `\n${await readFile(file, "utf8").catch(() => "")}`;
|
|
56
|
+
const theirs = buildTable({ css, source: "yours" });
|
|
57
|
+
const reports = [];
|
|
58
|
+
for await (const file of walkAll(roots)) {
|
|
59
|
+
const source = await readFile(file, "utf8").catch(() => null);
|
|
60
|
+
if (source === null)
|
|
61
|
+
continue;
|
|
62
|
+
reports.push(scanSource(file.slice(root.length + 1), source, installed.table));
|
|
63
|
+
}
|
|
64
|
+
const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40);
|
|
65
|
+
console.log(section("What the system could absorb"));
|
|
66
|
+
console.log(body(paint.dim(`read from ${rel.length > 0 ? rel.join(", ") : "this project"} · your own vocabulary read from the root`)));
|
|
67
|
+
console.log("");
|
|
68
|
+
for (const line of describeAbsorb(plan))
|
|
69
|
+
console.log(body(line));
|
|
70
|
+
if (plan.entries.length === 0)
|
|
71
|
+
return;
|
|
72
|
+
const proposal = {
|
|
73
|
+
slug: installed.table.slug,
|
|
74
|
+
at: new Date().toISOString(),
|
|
75
|
+
scope: rel,
|
|
76
|
+
entries: plan.entries,
|
|
77
|
+
more: plan.more,
|
|
78
|
+
};
|
|
79
|
+
await mkdir(dirname(path), { recursive: true });
|
|
80
|
+
await writeFile(path, `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
|
|
81
|
+
console.log("");
|
|
82
|
+
console.log(body(`Written to ${FILE} - nothing was sent.`));
|
|
83
|
+
const pending = needingName(plan).length;
|
|
84
|
+
if (pending > 0)
|
|
85
|
+
console.log(body(`Fill the ${pending} empty \`path\` field${pending === 1 ? "" : "s"} with the name you want (\`color.<family>.<step>\`, \`spacing.<key>\`), delete the lines you do not want, then:`));
|
|
86
|
+
else
|
|
87
|
+
console.log(body("Delete the lines you do not want and send the rest with:"));
|
|
88
|
+
console.log(paint.blue(snippet(["npx synthesisui@latest absorb --send"])));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* OS CAMINHOS QUE A FUNDAÇÃO JÁ TEM, lidos do documento que o `add` deixou ao lado dos tokens.
|
|
92
|
+
*
|
|
93
|
+
* Serve para a proposta nunca sugerir escrever num token existente: isso não seria absorver o design
|
|
94
|
+
* deles, seria repintar uma decisão que eles já tomaram. A plataforma recusa de novo do outro lado - ela
|
|
95
|
+
* é a autoridade -, e recusar aqui evita a pessoa preencher um nome que vai ser rejeitado.
|
|
96
|
+
*/
|
|
97
|
+
function pathsInSystem(documents) {
|
|
98
|
+
const out = new Set();
|
|
99
|
+
const walk = (node, path) => {
|
|
100
|
+
if (node && typeof node === "object" && !Array.isArray(node))
|
|
101
|
+
for (const [k, v] of Object.entries(node))
|
|
102
|
+
walk(v, [...path, k]);
|
|
103
|
+
else if (typeof node === "string" && path.length > 0)
|
|
104
|
+
out.add(path.join("."));
|
|
105
|
+
};
|
|
106
|
+
for (const doc of documents) {
|
|
107
|
+
const d = (doc ?? {});
|
|
108
|
+
const foundations = d.foundations ?? {};
|
|
109
|
+
const color = (foundations.color ?? {});
|
|
110
|
+
walk(color.primitives, ["color"]);
|
|
111
|
+
for (const [key, value] of Object.entries(foundations))
|
|
112
|
+
if (key !== "color")
|
|
113
|
+
walk(value, [key]);
|
|
114
|
+
walk(d.motion?.durations, ["motion", "durations"]);
|
|
115
|
+
walk(d.motion?.easings, ["motion", "easings"]);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** Manda o lote gravado. Só o que tem caminho viaja - o resto ainda espera um nome. */
|
|
120
|
+
async function sendProposal(root, path, registry) {
|
|
121
|
+
const raw = await readFile(path, "utf8").catch(() => null);
|
|
122
|
+
if (!raw) {
|
|
123
|
+
console.log(section("Absorb"));
|
|
124
|
+
console.log(body(`No proposal at ${FILE}. Run \`npx synthesisui@latest absorb\` first - it writes one and sends nothing.`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let proposal;
|
|
128
|
+
try {
|
|
129
|
+
proposal = JSON.parse(raw);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
console.log(section("Absorb"));
|
|
133
|
+
console.log(body(`${FILE} is not readable JSON anymore. Fix the file, or delete it and run \`absorb\` again.`));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const ready = (proposal.entries ?? []).filter((e) => typeof e.path === "string" && e.path.trim().length > 0);
|
|
137
|
+
const waiting = (proposal.entries ?? []).length - ready.length;
|
|
138
|
+
if (ready.length === 0) {
|
|
139
|
+
console.log(section("Absorb"));
|
|
140
|
+
console.log(body(`Every line in ${FILE} still has an empty \`path\`. A name is the one thing this cannot decide for you.`));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* O ALVO DECIDE SE ISSO SAI DA MÁQUINA - ver `ProjectConfig.absorb`.
|
|
145
|
+
*
|
|
146
|
+
* `code`: o vocabulário canônico é o CSS deles, então a nossa parte é devolver o bloco e a deles é
|
|
147
|
+
* colar. Nunca escrevemos no arquivo do cliente - e o import seguinte lê os tokens de volta, o que faz
|
|
148
|
+
* o caminho fechar sem nós tocarmos em nada.
|
|
149
|
+
*/
|
|
150
|
+
const target = (await readProjectConfig(root)).absorb ?? "system";
|
|
151
|
+
if (target === "code") {
|
|
152
|
+
console.log(section("Paste this into your own vocabulary"));
|
|
153
|
+
console.log(body(paint.dim("Your config says absorbed values belong in your CSS, so nothing was sent.")));
|
|
154
|
+
console.log("");
|
|
155
|
+
console.log(" :root {");
|
|
156
|
+
for (const e of ready)
|
|
157
|
+
console.log(` --${e.path.replace(/\./g, "-")}: ${e.value};`);
|
|
158
|
+
console.log(" }");
|
|
159
|
+
console.log("");
|
|
160
|
+
console.log(body("The next import or `sync` reads these back as tokens you declared, and from then on the doctor cobra them by your own names."));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const token = await readToken();
|
|
164
|
+
if (!token) {
|
|
165
|
+
console.log(section("Absorb"));
|
|
166
|
+
console.log(body("Sending needs a login - the tokens land in YOUR system:"));
|
|
167
|
+
console.log(paint.blue(snippet(["npx synthesisui@latest login"])));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const base = resolveRegistry(registry);
|
|
171
|
+
const res = await fetch(`${base}/api/registry/ds/${proposal.slug}/absorb`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"content-type": "application/json",
|
|
175
|
+
Authorization: `Bearer ${token}`,
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
tokens: ready.map((e) => ({
|
|
179
|
+
path: e.path.trim(),
|
|
180
|
+
value: e.value,
|
|
181
|
+
...(e.theirName ? { was: e.theirName } : {}),
|
|
182
|
+
})),
|
|
183
|
+
}),
|
|
184
|
+
}).catch(() => null);
|
|
185
|
+
console.log(section("Absorb"));
|
|
186
|
+
if (!res?.ok) {
|
|
187
|
+
console.log(body(res
|
|
188
|
+
? `The registry answered ${res.status}. Nothing was absorbed, and ${FILE} is untouched - fix and send again.`
|
|
189
|
+
: "Could not reach the registry. Nothing was absorbed."));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const answer = (await res.json());
|
|
193
|
+
console.log(body(`${answer.added ?? 0} token${answer.added === 1 ? "" : "s"} are now part of your system${answer.draft ? `, in the v${answer.draft} draft` : ""}.`));
|
|
194
|
+
for (const s of (answer.skipped ?? []).slice(0, 8))
|
|
195
|
+
console.log(body(paint.dim(` skipped ${s.path} - ${s.why}`)));
|
|
196
|
+
if (waiting > 0)
|
|
197
|
+
console.log(body(paint.dim(`${waiting} line${waiting === 1 ? "" : "s"} stayed behind with no name. They are still in ${FILE}.`)));
|
|
198
|
+
/**
|
|
199
|
+
* E O CICLO SE FECHA AQUI, dito em voz alta.
|
|
200
|
+
*
|
|
201
|
+
* Absorver não muda o código: os literais continuam escritos à mão até alguém trocá-los, e agora
|
|
202
|
+
* existe por quem trocar. Ninguém adivinharia essa ordem - foi ela que faltou na primeira conversa
|
|
203
|
+
* sobre migração (dono, 06/08).
|
|
204
|
+
*/
|
|
205
|
+
console.log("");
|
|
206
|
+
console.log(body("Two commands and the code catches up:"));
|
|
207
|
+
console.log(paint.blue(snippet([
|
|
208
|
+
`npx synthesisui@latest upgrade ${proposal.slug}`,
|
|
209
|
+
"npx synthesisui@latest doctor --fix",
|
|
210
|
+
])));
|
|
211
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { describeIntent, intentOf, readProjectConfig } from "../config.js";
|
|
3
4
|
import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
|
|
4
5
|
import { baselineOf, compareToBaseline, describeVerdict, githubAnnotations, sarifOf, } from "../doctor/ci-format.js";
|
|
5
6
|
import { findDivergences } from "../doctor/coherence.js";
|
|
@@ -13,6 +14,7 @@ import { checkableName, readRequests, verifyAndCloseRequests, } from "../doctor/
|
|
|
13
14
|
import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
|
|
14
15
|
import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
|
|
15
16
|
import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
|
|
17
|
+
import { describeScope, measuredScope, scopePaths } from "../measured-scope.js";
|
|
16
18
|
import { body, paint, section, snippet } from "../output.js";
|
|
17
19
|
import { resolveDeps } from "../stack.js";
|
|
18
20
|
/**
|
|
@@ -412,7 +414,32 @@ async function readWiring(root, slug) {
|
|
|
412
414
|
const DEFAULT_BASELINE = "_synthesisui/drift-baseline.json";
|
|
413
415
|
export async function doctor(opts) {
|
|
414
416
|
const root = resolve(opts.dir ?? process.cwd());
|
|
415
|
-
|
|
417
|
+
/**
|
|
418
|
+
* O ESCOPO QUE O IMPORT JÁ GRAVOU - ver `measured-scope.ts`.
|
|
419
|
+
*
|
|
420
|
+
* Um caminho passado à mão manda, sempre: quem digitou sabe o que quer. Sem ele, a resposta já está
|
|
421
|
+
* no disco (`census.json`: `scope` + `usage`), e varrer a raiz ignorando isso foi o que produziu uma
|
|
422
|
+
* lista de prioridades tirada de um app consumidor num repo de 2787 arquivos (dono, 06/08).
|
|
423
|
+
*/
|
|
424
|
+
const measured = (opts.scopes ?? []).length > 0
|
|
425
|
+
? { system: null, usage: [], from: "none" }
|
|
426
|
+
: await measuredScope(root);
|
|
427
|
+
const relScopes = (opts.scopes ?? []).length > 0
|
|
428
|
+
? opts.scopes
|
|
429
|
+
: scopePaths(measured);
|
|
430
|
+
const scopes = relScopes.map((s) => resolve(root, s));
|
|
431
|
+
/**
|
|
432
|
+
* CORRIDA COMPLETA É "NINGUÉM DIGITOU UM CAMINHO", e não "varreu tudo".
|
|
433
|
+
*
|
|
434
|
+
* O ledger só guarda retrato de corrida completa, porque a cobertura de um canto na linha do tempo do
|
|
435
|
+
* projeto lê como o projeto tendo melhorado quando alguém só apontou a ferramenta para outra pasta.
|
|
436
|
+
* Com o escopo agora vindo do censo, `scopes.length === 0` deixaria de acontecer - e o histórico
|
|
437
|
+
* pararia de existir em todo repo que tem censo. O que caracteriza a corrida completa é a INTENÇÃO de
|
|
438
|
+
* medir o projeto, e ela é: nenhum caminho na linha de comando.
|
|
439
|
+
*/
|
|
440
|
+
const fullRun = (opts.scopes ?? []).length === 0;
|
|
441
|
+
/** A intenção ordena o relatório, e a precedência é flag > config > default. */
|
|
442
|
+
const intent = intentOf(await readProjectConfig(root), opts.intent);
|
|
416
443
|
const installed = await loadSystem(root);
|
|
417
444
|
const { recipes, documents } = installed;
|
|
418
445
|
let table = installed.table;
|
|
@@ -533,9 +560,24 @@ export async function doctor(opts) {
|
|
|
533
560
|
: table.source === "yours"
|
|
534
561
|
? `Your own tokens - ${plural(table.byName.size, "token")} found, ${plural(d.scanned, "file")} read`
|
|
535
562
|
: `No system installed - ${plural(d.scanned, "file")} read`));
|
|
563
|
+
/**
|
|
564
|
+
* ONDE ELE LEU, SEMPRE - e de onde essa decisão veio.
|
|
565
|
+
*
|
|
566
|
+
* "2787 files read" sem dizer o universo é um número que a pessoa preenche com o que imagina, e ela
|
|
567
|
+
* imagina o sistema dela. Quando o escopo veio do censo, a frase nomeia os dois papéis: a pasta que
|
|
568
|
+
* É o sistema, e os apps que o CONSOMEM.
|
|
569
|
+
*/
|
|
536
570
|
if (scopes.length > 0) {
|
|
537
|
-
|
|
571
|
+
const said = measured.from === "census" ? describeScope(measured) : null;
|
|
572
|
+
console.log(body(said ?? `scope: ${relScopes.join(", ")}`));
|
|
538
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* E A INTENÇÃO, em toda rodada, com a data e o jeito de inverter.
|
|
576
|
+
*
|
|
577
|
+
* É o que impede uma fase declarada em agosto de ordenar o relatório em dezembro sem ninguém saber
|
|
578
|
+
* por quê - a mesma regra que fez `not observed since` existir em vez de um `fixed` inferido.
|
|
579
|
+
*/
|
|
580
|
+
console.log(body(paint.dim(describeIntent(intent))));
|
|
539
581
|
// What a token could never have held, said out loud. A report that quietly
|
|
540
582
|
// drops things is only trustworthy until someone notices, and then it is
|
|
541
583
|
// worth less than one that says too much.
|
|
@@ -574,6 +616,15 @@ export async function doctor(opts) {
|
|
|
574
616
|
// Fonts only count as missing when `init` actually wrote the file - a
|
|
575
617
|
// non-Next project has no fonts.ts and must not be nagged about one.
|
|
576
618
|
const fontsPending = wiring.fontsWritten && !wiring.fontsMapped;
|
|
619
|
+
/**
|
|
620
|
+
* A FIAÇÃO É PORTÃO, e não um aviso ao lado de um número.
|
|
621
|
+
*
|
|
622
|
+
* Sem o `@import` ou sem o `data-ds`, os tokens não chegam ao navegador. Até 06/08 a tela dizia isso
|
|
623
|
+
* e, três linhas abaixo, imprimia uma barra de 0% e uma lista de cinco prioridades - se contradizendo
|
|
624
|
+
* na mesma tela (dono: "fiquei perdido"). Ou o número não significa nada, ou existe uma lista de
|
|
625
|
+
* prioridades; as duas não cabem juntas.
|
|
626
|
+
*/
|
|
627
|
+
const blocked = hasSystem && (!wiring.imported || !wiring.scoped);
|
|
577
628
|
const unwired = table.source === "installed" &&
|
|
578
629
|
(!wiring.imported || !wiring.scoped || fontsPending);
|
|
579
630
|
if (unwired) {
|
|
@@ -598,10 +649,13 @@ export async function doctor(opts) {
|
|
|
598
649
|
// working and the faces are not - saying "nothing reaches the browser"
|
|
599
650
|
// there would be false, and a report that overstates is one nobody trusts
|
|
600
651
|
// the next time.
|
|
601
|
-
const blocked = !wiring.imported || !wiring.scoped;
|
|
602
652
|
if (blocked) {
|
|
603
|
-
|
|
604
|
-
|
|
653
|
+
/**
|
|
654
|
+
* A FRASE SEGUE O QUE VEM DEPOIS DELA. Ela dizia "o número abaixo não pode significar nada"
|
|
655
|
+
* enquanto imprimia o número abaixo; agora não há número abaixo, e prometer um seria a mesma
|
|
656
|
+
* incoerência ao contrário.
|
|
657
|
+
*/
|
|
658
|
+
console.log(body(`Until both are true, none of the ${table.byName.size} tokens reach the browser.`));
|
|
605
659
|
}
|
|
606
660
|
else {
|
|
607
661
|
console.log(body("Colour and spacing are working. Type is not: the system's faces"));
|
|
@@ -610,6 +664,41 @@ export async function doctor(opts) {
|
|
|
610
664
|
}
|
|
611
665
|
console.log(body("The exact snippets are in the output of `init`."));
|
|
612
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* E AQUI O RELATÓRIO PARA, quando a fiação está vermelha: uma instrução, e nada mais.
|
|
669
|
+
*
|
|
670
|
+
* O que fica de fora é justamente o que não pode ser verdade ainda - a barra de cobertura, a lista de
|
|
671
|
+
* prioridades e a oferta do `--fix`. O `--fix` é o mais grave: no repo do dono ele teria trocado 217
|
|
672
|
+
* literais por `var(--ds-…)` que não resolvem em lugar nenhum, e o navegador descarta a declaração -
|
|
673
|
+
* ou seja, o comando que promete adotar o sistema quebraria a aparência do app.
|
|
674
|
+
*
|
|
675
|
+
* O ledger é gravado ANTES de sair: a corrida aconteceu, e o histórico não pode depender de o
|
|
676
|
+
* projeto estar bem configurado.
|
|
677
|
+
*/
|
|
678
|
+
if (blocked) {
|
|
679
|
+
if (fullRun)
|
|
680
|
+
await appendEvent(root, {
|
|
681
|
+
kind: "doctor",
|
|
682
|
+
at: new Date().toISOString(),
|
|
683
|
+
coverage: d.coverage,
|
|
684
|
+
named: d.named,
|
|
685
|
+
...(d.findings.some((f) => f.crossFamily)
|
|
686
|
+
? { crossFamily: d.findings.filter((f) => f.crossFamily).length }
|
|
687
|
+
: {}),
|
|
688
|
+
}).catch(() => { });
|
|
689
|
+
console.log("");
|
|
690
|
+
if (opts.fix) {
|
|
691
|
+
console.log(body(`--fix is refused while those two are false. ${d.named} of your hand-written values`));
|
|
692
|
+
console.log(body("do have a name in the system, and swapping them now would point them at"));
|
|
693
|
+
console.log(body("variables the browser cannot resolve - the declarations would be dropped"));
|
|
694
|
+
console.log(body("and the page would change. Wire the two lines first."));
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
console.log(body(`${d.named} of the ${d.findings.length} hand-written values found already have a name in`));
|
|
698
|
+
console.log(body("your system, and that number becomes actionable the moment both are true."));
|
|
699
|
+
}
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
613
702
|
/**
|
|
614
703
|
* SHADCN IS THE MOST LIKELY THING ALREADY IN THE PROJECT, AND THE BRIDGE WAS
|
|
615
704
|
* INVISIBLE.
|
|
@@ -671,7 +760,7 @@ export async function doctor(opts) {
|
|
|
671
760
|
* question a design-system lead asks before paying for anything, and until
|
|
672
761
|
* today the honest answer was that nobody knew.
|
|
673
762
|
*/
|
|
674
|
-
if (hasSystem && measurable &&
|
|
763
|
+
if (hasSystem && measurable && fullRun) {
|
|
675
764
|
/**
|
|
676
765
|
* E A CONTAGEM DE COINCIDÊNCIAS VAI JUNTO - ver `crossFamily` em `scan.ts`.
|
|
677
766
|
*
|
|
@@ -688,7 +777,7 @@ export async function doctor(opts) {
|
|
|
688
777
|
...(crossFamily > 0 ? { crossFamily } : {}),
|
|
689
778
|
});
|
|
690
779
|
}
|
|
691
|
-
if (hasSystem &&
|
|
780
|
+
if (hasSystem && fullRun) {
|
|
692
781
|
const record = summarize(await readEvents(root));
|
|
693
782
|
if (record.checks >= 2) {
|
|
694
783
|
console.log("");
|
|
@@ -716,7 +805,7 @@ export async function doctor(opts) {
|
|
|
716
805
|
* the considered-and-rejected entries arrive already argued, by the party
|
|
717
806
|
* that actually hit the gap.
|
|
718
807
|
*/
|
|
719
|
-
if (hasSystem &&
|
|
808
|
+
if (hasSystem && fullRun) {
|
|
720
809
|
// Verification first: whether an ask is still missing is a FACT about
|
|
721
810
|
// the installed css, so the queue re-checks before it reports. A request
|
|
722
811
|
// the build now delivers closes itself and says so - nobody marks done
|
|
@@ -1143,18 +1232,31 @@ export async function doctor(opts) {
|
|
|
1143
1232
|
cheap: false,
|
|
1144
1233
|
});
|
|
1145
1234
|
}
|
|
1146
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* AS DUAS INTENÇÕES ORDENAM ESTA LISTA AO CONTRÁRIO UMA DA OUTRA.
|
|
1237
|
+
*
|
|
1238
|
+
* `adopt`: o que o sistema JÁ nomeia vem primeiro - é troca de uma linha e o `--fix` faz.
|
|
1239
|
+
* `migrate`: o que ele NÃO nomeia vem primeiro - é a decisão de design que a biblioteca tem que
|
|
1240
|
+
* absorver, e é o trabalho de quem está movendo design de um app para um pacote compartilhado.
|
|
1241
|
+
*
|
|
1242
|
+
* As duas listas continuam presentes nos dois modos (dono, 06/08): esconder metade faria a tela
|
|
1243
|
+
* mentir por omissão. Muda a ordem e muda quantos de cada aparecem no teto de cinco.
|
|
1244
|
+
*/
|
|
1245
|
+
const migrating = intent.intent === "migrate";
|
|
1246
|
+
const named = d.repeats.filter((x) => x.token);
|
|
1247
|
+
const unnamed = d.repeats.filter((x) => !x.token);
|
|
1248
|
+
for (const r of named.slice(0, migrating ? 2 : 3)) {
|
|
1147
1249
|
plan.push({
|
|
1148
|
-
rank: 3,
|
|
1250
|
+
rank: migrating ? 4 : 3,
|
|
1149
1251
|
what: `${r.literal} → ${r.token}`,
|
|
1150
1252
|
size: `${r.files} file${r.files === 1 ? "" : "s"}`,
|
|
1151
1253
|
cheap: true,
|
|
1152
1254
|
});
|
|
1153
1255
|
}
|
|
1154
|
-
for (const r of
|
|
1256
|
+
for (const r of unnamed.slice(0, migrating ? 3 : 2)) {
|
|
1155
1257
|
const near = nearestToken(table, r.literal);
|
|
1156
1258
|
plan.push({
|
|
1157
|
-
rank: 4,
|
|
1259
|
+
rank: migrating ? 3 : 4,
|
|
1158
1260
|
what: near
|
|
1159
1261
|
? `${r.literal} - name it, or snap to ${near.name}`
|
|
1160
1262
|
: `${r.literal} - the system has no name for it`,
|
|
@@ -1318,7 +1420,8 @@ export async function doctor(opts) {
|
|
|
1318
1420
|
await writeFile(path, `${JSON.stringify(baselineOf(d, {
|
|
1319
1421
|
at: new Date().toISOString(),
|
|
1320
1422
|
...(opts.cli ? { cli: opts.cli } : {}),
|
|
1321
|
-
|
|
1423
|
+
/** O escopo EFETIVO, e não a flag: comparar dois universos diferentes inventa regressão. */
|
|
1424
|
+
...(relScopes.length ? { scope: relScopes.join(",") } : {}),
|
|
1322
1425
|
}), null, 1)}\n`, "utf8");
|
|
1323
1426
|
console.log(section("Baseline written"));
|
|
1324
1427
|
console.log(body(`${d.findings.length} findings and ${d.phantomUses} phantom uses are now the line to hold. A run that goes above it fails; everything at or below it passes.`));
|
|
@@ -1337,7 +1440,7 @@ export async function doctor(opts) {
|
|
|
1337
1440
|
return;
|
|
1338
1441
|
}
|
|
1339
1442
|
const verdict = compareToBaseline(d, base, {
|
|
1340
|
-
...(
|
|
1443
|
+
...(relScopes.length ? { scope: relScopes.join(",") } : {}),
|
|
1341
1444
|
});
|
|
1342
1445
|
console.log(section(verdict.worse ? "Drift went up" : "Ratchet"));
|
|
1343
1446
|
for (const line of describeVerdict(verdict))
|
package/dist/commands/init.js
CHANGED
|
@@ -15,6 +15,16 @@ export async function init(opts) {
|
|
|
15
15
|
pagesDir: opts.pagesDir ?? (target === "next" ? "app" : DEFAULT_CONFIG.pagesDir),
|
|
16
16
|
componentsDir: opts.componentsDir ?? DEFAULT_CONFIG.componentsDir,
|
|
17
17
|
styles: opts.styles === "tailwind" ? "tailwind" : "css",
|
|
18
|
+
/**
|
|
19
|
+
* Gravada COM A DATA, e só quando alguém respondeu. Migração é uma fase que termina, e uma fase sem
|
|
20
|
+
* data é a etiqueta que envelhece em silêncio - o doctor imprime as duas em toda rodada.
|
|
21
|
+
*/
|
|
22
|
+
...(opts.intent === "migrate" || opts.intent === "adopt"
|
|
23
|
+
? { intent: opts.intent, intentAt: new Date().toISOString() }
|
|
24
|
+
: {}),
|
|
25
|
+
...(opts.absorb === "code" || opts.absorb === "system"
|
|
26
|
+
? { absorb: opts.absorb }
|
|
27
|
+
: {}),
|
|
18
28
|
};
|
|
19
29
|
await writeProjectConfig(root, config);
|
|
20
30
|
console.log("✓ wrote _synthesisui/config.json");
|
|
@@ -22,6 +32,10 @@ export async function init(opts) {
|
|
|
22
32
|
console.log(` pagesDir: ${config.pagesDir}`);
|
|
23
33
|
console.log(` componentsDir: ${config.componentsDir}`);
|
|
24
34
|
console.log(` styles: ${config.styles}`);
|
|
35
|
+
if (config.absorb)
|
|
36
|
+
console.log(` absorb: ${config.absorb}${config.absorb === "code" ? " (we hand you the CSS block; your file stays yours)" : " (absorbed values become tokens of the system)"}`);
|
|
37
|
+
if (config.intent)
|
|
38
|
+
console.log(` intent: ${config.intent}${config.intent === "migrate" ? " (the doctor leads with what your system does not name yet)" : " (the doctor leads with what your system already names)"}`);
|
|
25
39
|
// --ds bootstraps the project with a system in one step (tokens + philosophy
|
|
26
40
|
// + rules + CLAUDE.md all arrive via `add`).
|
|
27
41
|
if (opts.ds) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { measuredScope } from "../measured-scope.js";
|
|
4
|
+
import { body, paint, section, snippet } from "../output.js";
|
|
5
|
+
/**
|
|
6
|
+
* `synthesisui summary <slug>` - AS QUATRO LINHAS DO PRIMEIRO MINUTO, impressas por um programa.
|
|
7
|
+
*
|
|
8
|
+
* A skill `sui-init` já mandava terminar em quatro linhas, e listava o que nunca deveria aparecer
|
|
9
|
+
* nelas: quantos arquivos foram varridos, quanto tempo levou, quais leitores rodaram, o que o ledger
|
|
10
|
+
* diz. O fechamento que o dono recebeu em 06/08 tinha ~400 palavras e violava as duas regras - com
|
|
11
|
+
* três dos quatro parágrafos falando do AGENTE e não do sistema dele:
|
|
12
|
+
*
|
|
13
|
+
* "my notes from 2026-08-05 carry two confirmed defects" a memoria do agente
|
|
14
|
+
* "the generator had been deleted, so I rebuilt it" o retrabalho do agente
|
|
15
|
+
* "a census regression I stripped by hand" o conserto do agente
|
|
16
|
+
*
|
|
17
|
+
* E duas daquelas advertências estavam DESATUALIZADAS: os dois defeitos do `--fix` foram consertados
|
|
18
|
+
* na 0.16.153, então a skill assustou o usuário com um bug que não existe mais.
|
|
19
|
+
*
|
|
20
|
+
* Mais prosa proibindo não conserta isso. O que o agente tem em mãos no fim é justamente o próprio
|
|
21
|
+
* trabalho dele, e a atração de reportá-lo vence uma linha de proibição a sessenta linhas de distância.
|
|
22
|
+
* Então o fechamento sai das mãos dele: este comando sabe as contagens (o censo está no disco), sabe a
|
|
23
|
+
* versão instalada (o `.lock`), e recebe o resto por flag. Um agente que executa isto não tem o que
|
|
24
|
+
* compor - e uma anotação velha dele não tem por onde entrar.
|
|
25
|
+
*
|
|
26
|
+
* LOCAL, E NUNCA FALHA. Sem censo ele ainda diz o essencial; sem rede ele não perde nada, porque nada
|
|
27
|
+
* aqui é remoto.
|
|
28
|
+
*/
|
|
29
|
+
export async function summary(slug, opts) {
|
|
30
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
31
|
+
const census = await readFile(join(root, "_synthesisui", "census.json"), "utf8")
|
|
32
|
+
.then((raw) => JSON.parse(raw))
|
|
33
|
+
.catch(() => null);
|
|
34
|
+
const lock = await readFile(join(root, "_synthesisui", "ds", slug, ".lock"), "utf8")
|
|
35
|
+
.then((raw) => JSON.parse(raw))
|
|
36
|
+
.catch(() => null);
|
|
37
|
+
const measured = await measuredScope(root);
|
|
38
|
+
const name = lock?.name ?? slug;
|
|
39
|
+
/**
|
|
40
|
+
* `looks`, NUNCA `components`.
|
|
41
|
+
*
|
|
42
|
+
* `components` é a lista de candidatos que o portão avaliou - 128 no repo do dono, contra os 36 que
|
|
43
|
+
* o sistema realmente tem. Dizer 128 no primeiro minuto é prometer quatro vezes o que foi entregue,
|
|
44
|
+
* e o número certo já estava no mesmo arquivo.
|
|
45
|
+
*/
|
|
46
|
+
const components = census?.looks ? Object.keys(census.looks).length : null;
|
|
47
|
+
const tokens = census?.declared ? Object.keys(census.declared).length : null;
|
|
48
|
+
console.log(section(`${name} is yours`));
|
|
49
|
+
/**
|
|
50
|
+
* LINHA 1 - O QUE ELE TEM, nas contagens dele. Nunca "36 de 128 candidatos", nunca "2787 arquivos":
|
|
51
|
+
* o que importa no primeiro minuto é o tamanho do que ele ganhou e de onde saiu.
|
|
52
|
+
*/
|
|
53
|
+
const from = measured.system ? `, read out of ${measured.system}` : "";
|
|
54
|
+
console.log(body(components != null && tokens != null
|
|
55
|
+
? `${components} components and ${tokens} of your own tokens${from}.`
|
|
56
|
+
: `Installed${lock?.version ? ` at v${lock.version}` : ""}${from}.`));
|
|
57
|
+
/**
|
|
58
|
+
* LINHA 2 - AS DUAS VERSÕES E O CAMINHO DE VOLTA, com os números que a PLATAFORMA deu.
|
|
59
|
+
*
|
|
60
|
+
* Escrever "v1" e "v2" aqui por conta própria já esteve errado uma vez: enquanto o nascimento
|
|
61
|
+
* publicava uma baseline, o import caía na v2 e o congelamento produzia a v3. Então os números
|
|
62
|
+
* chegam por flag, e sem eles esta linha simplesmente não é dita.
|
|
63
|
+
*/
|
|
64
|
+
if (opts.base != null && opts.draft != null) {
|
|
65
|
+
console.log(body(`v${opts.base} is your code as it is today${opts.fixes ? ` · v${opts.draft} adds ${opts.fixes} deterministic ${opts.fixes === 1 ? "fix" : "fixes"}` : ` · v${opts.draft} is the draft`}.`));
|
|
66
|
+
console.log(body(paint.dim(`Back to v${opts.base} is one click: /dashboard/mine/${slug}/publish`)));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* LINHA 3 - O QUE FAZER AGORA, e é uma coisa só.
|
|
70
|
+
*
|
|
71
|
+
* As duas linhas de fiação são o portão de tudo o mais: sem elas nenhum token chega ao navegador e
|
|
72
|
+
* todo número seguinte é vazio. O `doctor` diz isso melhor e com o estado real, então o próximo passo
|
|
73
|
+
* é ele - não uma explicação nossa aqui.
|
|
74
|
+
*/
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log(body("Two lines wire it up, and `init` printed both:"));
|
|
77
|
+
console.log(body(paint.dim(` @import "./_synthesisui/ds/${slug}/tokens.css"; and data-ds="${slug}" on <html>`)));
|
|
78
|
+
console.log(paint.blue(snippet(["npx synthesisui@latest doctor"])));
|
|
79
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -98,12 +98,66 @@ export async function readProjectConfig(root) {
|
|
|
98
98
|
? parsed.componentsDir
|
|
99
99
|
: DEFAULT_CONFIG.componentsDir,
|
|
100
100
|
styles: parsed.styles === "tailwind" ? "tailwind" : "css",
|
|
101
|
+
/**
|
|
102
|
+
* A INTENÇÃO NÃO TEM DEFAULT AQUI, de propósito.
|
|
103
|
+
*
|
|
104
|
+
* Um default silencioso é uma decisão nossa vestida de decisão deles: quem não respondeu não
|
|
105
|
+
* escolheu, e o doctor tem que poder dizer "ninguém declarou" em vez de ordenar por um palpite.
|
|
106
|
+
* Quem consome isto é `intentOf`, que separa os três casos.
|
|
107
|
+
*/
|
|
108
|
+
...(parsed.intent === "migrate" || parsed.intent === "adopt"
|
|
109
|
+
? { intent: parsed.intent }
|
|
110
|
+
: {}),
|
|
111
|
+
...(typeof parsed.intentAt === "string" && parsed.intentAt
|
|
112
|
+
? { intentAt: parsed.intentAt }
|
|
113
|
+
: {}),
|
|
114
|
+
/** Sem valor gravado o comando usa `system`, que é o default do produto e não uma suposição. */
|
|
115
|
+
...(parsed.absorb === "code" || parsed.absorb === "system"
|
|
116
|
+
? { absorb: parsed.absorb }
|
|
117
|
+
: {}),
|
|
101
118
|
};
|
|
102
119
|
}
|
|
103
120
|
catch {
|
|
104
121
|
return DEFAULT_CONFIG;
|
|
105
122
|
}
|
|
106
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* A PRECEDÊNCIA, num lugar só: flag > config > default.
|
|
126
|
+
*
|
|
127
|
+
* E ela é IMPRESSA por quem chama, sempre. Estado persistido que não se anuncia é a etiqueta que
|
|
128
|
+
* envelhece em silêncio - o mesmo defeito que a tela de causas acabou de perder (dono, 06/08). Uma
|
|
129
|
+
* linha dizendo "migrate, definido em 06/08, --adopt inverte" transforma estado velho em fato.
|
|
130
|
+
*
|
|
131
|
+
* O default é `adopt` porque é o que uma pessoa com um sistema instalado quer na maioria dos dias, e
|
|
132
|
+
* porque é o único dos dois cuja lista tem conserto automático (`--fix`). Migrar é uma fase, e uma
|
|
133
|
+
* fase se declara.
|
|
134
|
+
*/
|
|
135
|
+
export function intentOf(config, flag) {
|
|
136
|
+
if (flag === "migrate" || flag === "adopt")
|
|
137
|
+
return { intent: flag, from: "flag" };
|
|
138
|
+
if (config.intent)
|
|
139
|
+
return {
|
|
140
|
+
intent: config.intent,
|
|
141
|
+
from: "config",
|
|
142
|
+
...(config.intentAt ? { at: config.intentAt } : {}),
|
|
143
|
+
};
|
|
144
|
+
return { intent: "adopt", from: "default" };
|
|
145
|
+
}
|
|
146
|
+
/** A frase que acompanha o relatório - ver `intentOf` para por que ela é obrigatória. */
|
|
147
|
+
export function describeIntent(source) {
|
|
148
|
+
const what = source.intent === "migrate"
|
|
149
|
+
? "reading as a MIGRATION - values your system does not name yet come first"
|
|
150
|
+
: "reading as ADOPTION - values your system already names come first";
|
|
151
|
+
const where = source.from === "flag"
|
|
152
|
+
? "this run only"
|
|
153
|
+
: source.from === "config"
|
|
154
|
+
? `set in _synthesisui/config.json${source.at ? ` on ${source.at.slice(0, 10)}` : ""}`
|
|
155
|
+
: "nobody declared one, so this is the default";
|
|
156
|
+
const flip = source.intent === "migrate"
|
|
157
|
+
? "`doctor --adopt` flips it"
|
|
158
|
+
: "`doctor --migrate` flips it";
|
|
159
|
+
return `${what} · ${where} · ${flip}`;
|
|
160
|
+
}
|
|
107
161
|
/** Writes the project config (committable, plain JSON). */
|
|
108
162
|
export async function writeProjectConfig(root, config) {
|
|
109
163
|
const path = projectConfigPath(root);
|
package/dist/doctor/frozen.js
CHANGED
|
@@ -19,7 +19,19 @@
|
|
|
19
19
|
* actually differs between the two schemes; a role that is the same in both
|
|
20
20
|
* costs nothing to spell either way, and this stays quiet about it.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* A GRAMÁTICA DE UMA REF DE PRIMITIVA, e ela recusava hífen nos DOIS segmentos.
|
|
24
|
+
*
|
|
25
|
+
* Contada em 06/08: a mesma pergunta era feita por quatro regexes e as quatro discordavam - duas
|
|
26
|
+
* aceitavam hífen só na família, uma aceitava nos dois, e esta em nenhum. Consequência medida no repo do
|
|
27
|
+
* dono, que tem as famílias `royal-blue`, `vivid-pink` e `electric-indigo`: toda ref para elas parseava
|
|
28
|
+
* como nada aqui, e esta checagem passava calada por cima delas - um achado que não existe é
|
|
29
|
+
* indistinguível de um achado que não há.
|
|
30
|
+
*
|
|
31
|
+
* Gêmea de `PRIMITIVE_REF_PATTERN` em `libs/ds-contracts/src/token-ref.ts`, copiada e não importada
|
|
32
|
+
* porque o CLI é publicado standalone. O spec ao lado assere a fonte da regex nos dois lados.
|
|
33
|
+
*/
|
|
34
|
+
const PRIMITIVE = /^\{color\.([a-z0-9-]+)\.([a-z0-9-]+)\}$/i;
|
|
23
35
|
const BACKGROUND = /^background(-color)?$/;
|
|
24
36
|
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
25
37
|
function* colorBindings(node, where) {
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { absorb } from "./commands/absorb.js";
|
|
3
4
|
import { add } from "./commands/add.js";
|
|
4
5
|
import { adopt } from "./commands/adopt.js";
|
|
5
6
|
import { advise } from "./commands/advise.js";
|
|
@@ -19,6 +20,7 @@ import { mcp } from "./commands/mcp.js";
|
|
|
19
20
|
import { refit } from "./commands/refit.js";
|
|
20
21
|
import { request } from "./commands/request.js";
|
|
21
22
|
import { status } from "./commands/status.js";
|
|
23
|
+
import { summary } from "./commands/summary.js";
|
|
22
24
|
import { sync } from "./commands/sync.js";
|
|
23
25
|
import { template } from "./commands/template.js";
|
|
24
26
|
import { upgrade } from "./commands/upgrade.js";
|
|
@@ -99,6 +101,10 @@ Options:
|
|
|
99
101
|
--strict doctor: exit 1 when drift is found in THIS repo (for CI)
|
|
100
102
|
--strict-system doctor: also exit 1 when the system itself is inconsistent
|
|
101
103
|
--verbose doctor: every finding, file by file (default is a summary)
|
|
104
|
+
--send absorb: send the batch you reviewed (default writes the file)
|
|
105
|
+
--migrate doctor: lead with what your system does NOT name yet - the
|
|
106
|
+
order for moving design INTO a shared package
|
|
107
|
+
--adopt doctor: lead with what your system already names (default)
|
|
102
108
|
--laws doctor: show every usage law, not just the busiest components
|
|
103
109
|
--out <path> output path for the generated template (default: <pagesDir>/<file>)
|
|
104
110
|
-h, --help this help
|
|
@@ -279,9 +285,57 @@ async function main() {
|
|
|
279
285
|
*/
|
|
280
286
|
...(flags.write === true ? { write: true } : {}),
|
|
281
287
|
...(flags["write-baseline"] === true ? { writeBaseline: true } : {}),
|
|
288
|
+
/**
|
|
289
|
+
* A INTENÇÃO DESTA RODADA - atalho para `config.json`, ver `intentOf`.
|
|
290
|
+
*
|
|
291
|
+
* Duas flags e não um valor (`--intent=migrate`), porque o que se digita no meio de uma tarefa
|
|
292
|
+
* é uma palavra: `--migrate` ou `--adopt`.
|
|
293
|
+
*/
|
|
294
|
+
...(flags.migrate === true
|
|
295
|
+
? { intent: "migrate" }
|
|
296
|
+
: flags.adopt === true
|
|
297
|
+
? { intent: "adopt" }
|
|
298
|
+
: {}),
|
|
282
299
|
cli: CLI_VERSION,
|
|
283
300
|
});
|
|
284
301
|
break;
|
|
302
|
+
case "absorb":
|
|
303
|
+
/**
|
|
304
|
+
* O OUTRO SENTIDO: o sistema aprende o design que o código já tem - ver `commands/absorb.ts`.
|
|
305
|
+
*
|
|
306
|
+
* Sem flag ESCREVE uma proposta e não fala com a rede; `--send` manda o lote. A fase do meio é
|
|
307
|
+
* humana de propósito: nomear uma cor é decisão de design.
|
|
308
|
+
*/
|
|
309
|
+
await absorb({
|
|
310
|
+
dir,
|
|
311
|
+
registry,
|
|
312
|
+
...(flags.send === true ? { send: true } : {}),
|
|
313
|
+
});
|
|
314
|
+
break;
|
|
315
|
+
case "summary": {
|
|
316
|
+
/**
|
|
317
|
+
* AS QUATRO LINHAS DO PRIMEIRO MINUTO - ver `commands/summary.ts`.
|
|
318
|
+
*
|
|
319
|
+
* Existe para tirar o fechamento do `sui-init` das mãos do agente: ele terminou com ~400 palavras
|
|
320
|
+
* sobre o próprio trabalho, incluindo duas advertências já desatualizadas (dono, 06/08).
|
|
321
|
+
*/
|
|
322
|
+
/** `args` já exclui o comando - o slug é o primeiro posicional, como em `template <slug>`. */
|
|
323
|
+
const target = args[0];
|
|
324
|
+
if (!target) {
|
|
325
|
+
console.log("usage: synthesisui summary <slug> [--base N --draft N --fixes N]");
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
const num = (v) => typeof v === "string" && /^\d+$/.test(v)
|
|
329
|
+
? Number.parseInt(v, 10)
|
|
330
|
+
: undefined;
|
|
331
|
+
await summary(target, {
|
|
332
|
+
dir,
|
|
333
|
+
...(num(flags.base) != null ? { base: num(flags.base) } : {}),
|
|
334
|
+
...(num(flags.draft) != null ? { draft: num(flags.draft) } : {}),
|
|
335
|
+
...(num(flags.fixes) != null ? { fixes: num(flags.fixes) } : {}),
|
|
336
|
+
});
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
285
339
|
case "status":
|
|
286
340
|
/** A resposta do dia a dia no terminal - ver `commands/status.ts`. Local primeiro. */
|
|
287
341
|
await status({ dir, registry, cli: CLI_VERSION });
|
|
@@ -338,11 +392,28 @@ async function main() {
|
|
|
338
392
|
: undefined;
|
|
339
393
|
const ds = typeof flags.ds === "string" ? flags.ds : undefined;
|
|
340
394
|
const styles = typeof flags.styles === "string" ? flags.styles : undefined;
|
|
395
|
+
/**
|
|
396
|
+
* A INTENÇÃO, gravada com a data - ver `ProjectConfig.intent`.
|
|
397
|
+
*
|
|
398
|
+
* Aceita as duas formas porque as duas são naturais: `--intent migrate` quando um agente monta o
|
|
399
|
+
* comando, `--migrate` quando uma pessoa digita.
|
|
400
|
+
*/
|
|
401
|
+
const intent = typeof flags.intent === "string"
|
|
402
|
+
? flags.intent
|
|
403
|
+
: flags.migrate === true
|
|
404
|
+
? "migrate"
|
|
405
|
+
: flags.adopt === true
|
|
406
|
+
? "adopt"
|
|
407
|
+
: undefined;
|
|
341
408
|
await init({
|
|
342
409
|
dir,
|
|
343
410
|
registry,
|
|
344
411
|
target,
|
|
345
412
|
pagesDir,
|
|
413
|
+
...(flags.absorb === "code" || flags.absorb === "system"
|
|
414
|
+
? { absorb: flags.absorb }
|
|
415
|
+
: {}),
|
|
416
|
+
...(intent ? { intent } : {}),
|
|
346
417
|
componentsDir,
|
|
347
418
|
styles,
|
|
348
419
|
ds,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
const EMPTY = { system: null, usage: [], from: "none" };
|
|
4
|
+
/** Existe como diretório? Um escopo gravado e depois renomeado não pode derrubar o comando. */
|
|
5
|
+
async function present(root, rel) {
|
|
6
|
+
return stat(resolve(root, rel)).then((s) => s.isDirectory(), () => false);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Lê `_synthesisui/census.json` e devolve o que ele diz sobre ONDE.
|
|
10
|
+
*
|
|
11
|
+
* Só caminhos que ainda existem: o censo é uma foto de quando o import rodou, e uma pasta renomeada
|
|
12
|
+
* desde então não pode fazer o doctor estourar - ela simplesmente sai da lista, e a lista vazia cai no
|
|
13
|
+
* comportamento antigo (a raiz).
|
|
14
|
+
*/
|
|
15
|
+
export async function measuredScope(root) {
|
|
16
|
+
const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => null);
|
|
17
|
+
if (!raw)
|
|
18
|
+
return EMPTY;
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return EMPTY;
|
|
25
|
+
}
|
|
26
|
+
const scope = typeof parsed.scope === "string" && parsed.scope.trim()
|
|
27
|
+
? parsed.scope.trim()
|
|
28
|
+
: null;
|
|
29
|
+
const usage = Array.isArray(parsed.usage)
|
|
30
|
+
? parsed.usage.filter((u) => typeof u === "string" && !!u.trim())
|
|
31
|
+
: [];
|
|
32
|
+
const system = scope && (await present(root, scope)) ? scope : null;
|
|
33
|
+
const kept = [];
|
|
34
|
+
for (const u of usage)
|
|
35
|
+
if (await present(root, u))
|
|
36
|
+
kept.push(u);
|
|
37
|
+
if (!system && kept.length === 0)
|
|
38
|
+
return EMPTY;
|
|
39
|
+
return { system, usage: kept, from: "census" };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* As pastas a LER, na ordem em que importam - o sistema primeiro.
|
|
43
|
+
*
|
|
44
|
+
* A ordem não é cosmética: ela decide qual arquivo entra primeiro no relatório e, com teto de
|
|
45
|
+
* anotações no CI, decide o que chega ao PR.
|
|
46
|
+
*/
|
|
47
|
+
export function scopePaths(m) {
|
|
48
|
+
return [...(m.system ? [m.system] : []), ...m.usage];
|
|
49
|
+
}
|
|
50
|
+
/** Como dizer o que foi lido. Uma leitura que não declara o universo é um número sem denominador. */
|
|
51
|
+
export function describeScope(m) {
|
|
52
|
+
if (m.from === "none")
|
|
53
|
+
return null;
|
|
54
|
+
const parts = [
|
|
55
|
+
...(m.system ? [`${m.system} (the system)`] : []),
|
|
56
|
+
...(m.usage.length > 0
|
|
57
|
+
? [
|
|
58
|
+
`${m.usage.join(", ")} (${m.usage.length === 1 ? "consumer" : "consumers"})`,
|
|
59
|
+
]
|
|
60
|
+
: []),
|
|
61
|
+
];
|
|
62
|
+
return `read from your census: ${parts.join(" · ")}`;
|
|
63
|
+
}
|
package/dist/skill-init.js
CHANGED
|
@@ -32,20 +32,22 @@ visual, semantic and functional parity, without supposing and without inventing
|
|
|
32
32
|
|
|
33
33
|
## THE SHAPE OF THIS RUN, AND WHY IT IS THIS SHAPE
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Five questions, asked before anything that needs an answer. Then silence, then four lines.
|
|
36
36
|
|
|
37
37
|
\`\`\`
|
|
38
38
|
1 environment which agent they work in - and what that agent can actually get
|
|
39
39
|
2 authorisation may we optimise after importing, and which parts
|
|
40
40
|
3 project WHICH folder becomes the system (the only unavoidable choice)
|
|
41
41
|
4 identity name, primary, theme - each already answered by the census
|
|
42
|
+
5 intent adopting the system, or migrating design INTO it
|
|
43
|
+
(+ where absorbed values live, only if they are migrating)
|
|
42
44
|
---- nobody waits from here on ----
|
|
43
|
-
import
|
|
44
|
-
|
|
45
|
+
init -> import -> freeze -> apply what they authorised -> four lines
|
|
46
|
+
6 offer the two lines that wire it up
|
|
45
47
|
\`\`\`
|
|
46
48
|
|
|
47
49
|
**Every question is asked before the work, and the work starts during the questions.** A person who
|
|
48
|
-
answers
|
|
50
|
+
answers five things in ninety seconds and then watches one bar has had a good four minutes; the same
|
|
49
51
|
person interrupted twice mid-silence has had a bad twenty (dono, 01/08).
|
|
50
52
|
|
|
51
53
|
**What runs in parallel, precisely.** The UNSCOPED census - \`npx synthesisui import --dry\` - needs no
|
|
@@ -161,6 +163,59 @@ in the \`sui-import-ds\` skill; do not reinvent it here, and do not ask anything
|
|
|
161
163
|
|
|
162
164
|
---
|
|
163
165
|
|
|
166
|
+
## 5. INTENT
|
|
167
|
+
|
|
168
|
+
The one question the census cannot answer, and the only one whose answer changes what a later command
|
|
169
|
+
puts first.
|
|
170
|
+
|
|
171
|
+
\`\`\`
|
|
172
|
+
What are you doing with this system?
|
|
173
|
+
|
|
174
|
+
Adopting it (recommended) make the code use the system. The doctor leads with the
|
|
175
|
+
values your system already names - those have a one-line
|
|
176
|
+
fix, and \`doctor --fix\` does them.
|
|
177
|
+
Migrating into it you are moving design out of your apps and into the shared
|
|
178
|
+
package. The doctor leads with the values your system does
|
|
179
|
+
NOT name yet, by frequency - those are the tokens the
|
|
180
|
+
library still has to absorb.
|
|
181
|
+
\`\`\`
|
|
182
|
+
|
|
183
|
+
Both lists exist in both modes. The answer decides the ORDER, and the order decides what somebody
|
|
184
|
+
does first - measured on a real monorepo (dono, 06/08): with no intent declared, four of the five
|
|
185
|
+
suggestions came out of a consumer app, led by a colour that does not belong to the system's palette
|
|
186
|
+
at all. The same number is noise under one intent and the strongest candidate in the repo under the
|
|
187
|
+
other.
|
|
188
|
+
|
|
189
|
+
Pass the answer straight through:
|
|
190
|
+
|
|
191
|
+
\`\`\`
|
|
192
|
+
npx synthesisui@latest init --intent adopt (or --intent migrate)
|
|
193
|
+
\`\`\`
|
|
194
|
+
|
|
195
|
+
**One follow-up, and ONLY when they chose migrating.** Absorbing is what a migration does; asking an
|
|
196
|
+
adopter where absorbed values would go is asking about a command they will not run this month.
|
|
197
|
+
|
|
198
|
+
\`\`\`
|
|
199
|
+
When your system absorbs a value, where should it live?
|
|
200
|
+
|
|
201
|
+
In the system (recommended) it becomes a token of the design system, and the
|
|
202
|
+
tokens.css it generates starts emitting it
|
|
203
|
+
In your own CSS we hand you the block and you paste it into your
|
|
204
|
+
vocabulary; the next import reads it back as a
|
|
205
|
+
token you declared
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
Then \`--absorb system\` or \`--absorb code\` on the same \`init\`. Both answers are real: the second exists
|
|
209
|
+
because in some teams the canonical vocabulary IS their stylesheet, and in that case our part is to hand
|
|
210
|
+
over the snippet - never to write in their file.
|
|
211
|
+
|
|
212
|
+
**Run \`init\` even when nothing else needs it.** It writes \`_synthesisui/config.json\` - the committed
|
|
213
|
+
file that holds \`target\`, \`pagesDir\`, \`componentsDir\`, \`styles\` and this intent - and until 06/08 this
|
|
214
|
+
run never called it: a full first run left no config at all, so \`template\` and \`component\` fell back to
|
|
215
|
+
defaults and the intent had nowhere to live (dono, 06/08).
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
164
219
|
## THE SILENT PART
|
|
165
220
|
|
|
166
221
|
Now nobody is asked anything.
|
|
@@ -209,46 +264,46 @@ answers 400, and a 400 in a first run reads as a broken product.
|
|
|
209
264
|
|
|
210
265
|
---
|
|
211
266
|
|
|
212
|
-
##
|
|
267
|
+
## 6. THE FOUR LINES
|
|
213
268
|
|
|
214
|
-
This is the whole point of the run
|
|
269
|
+
This is the whole point of the run, and **it is not yours to compose.** Run this and print nothing
|
|
270
|
+
else:
|
|
215
271
|
|
|
216
272
|
\`\`\`
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
v2 is frozen: your system exactly as your code has it today. It never moves.
|
|
220
|
-
v3 is what you are looking at - the same system with 118 deterministic fixes: contrast on
|
|
221
|
-
text only, lengths onto your own scale, near-identical colours unified.
|
|
222
|
-
|
|
223
|
-
Back to v2 is one click: /dashboard/mine/signalui/publish
|
|
273
|
+
npx synthesisui@latest summary <slug> --base <base> --draft <draft> --fixes <how many>
|
|
224
274
|
\`\`\`
|
|
225
275
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
v3. Proved against production before this skill ever ran (05/08) - and a first screen that points at
|
|
230
|
-
the wrong version to restore is worse than no version at all.
|
|
276
|
+
It prints their component count, their own token count, where the system was read from, the two
|
|
277
|
+
versions with the way back, and the one thing to do next. The numbers for \`--base\` and \`--draft\` come
|
|
278
|
+
from the \`optimize\` RESPONSE - never invented here, because the platform is what numbers a version.
|
|
231
279
|
|
|
232
|
-
**
|
|
233
|
-
|
|
234
|
-
|
|
280
|
+
**Why a command instead of four lines you write.** The last screen of this run has ended long, more
|
|
281
|
+
than once - up to four hundred words, with three of its four paragraphs about the AGENT rather than
|
|
282
|
+
about their system: notes from a previous session, a generator that had been deleted and rebuilt, a
|
|
283
|
+
regression stripped by hand. Two of those notes were also out of date, which is worse than verbose: it
|
|
284
|
+
warned somebody about defects that no longer existed (dono, 06/08).
|
|
235
285
|
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
The pull to report your own work at the end of a long job beats a rule written sixty lines earlier. So
|
|
287
|
+
the rule became a program. What you have in hand when the run ends - your notes, your retries, your
|
|
288
|
+
repairs - has no way into a block that a command prints.
|
|
238
289
|
|
|
239
|
-
|
|
290
|
+
**Anything you think belongs on that screen and is not in the command's output does not belong on that
|
|
291
|
+
screen.** If it is a real finding, it is already in the report or in the causes screen; if it is about
|
|
292
|
+
what you did, it is not theirs.
|
|
240
293
|
|
|
241
|
-
|
|
294
|
+
## 7. THE OFFER
|
|
242
295
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
296
|
+
There is exactly one thing to offer, and it is not the fix: it is the two lines that wire the system
|
|
297
|
+
up. Until a stylesheet imports the tokens and an element carries \`data-ds\`, none of the tokens reach
|
|
298
|
+
the browser.
|
|
246
299
|
|
|
247
|
-
|
|
248
|
-
\`\`\`
|
|
300
|
+
\`summary\` already prints both. Say nothing more about them.
|
|
249
301
|
|
|
250
|
-
|
|
251
|
-
|
|
302
|
+
**Never offer \`doctor --fix\` here.** It was offered for a while, with the number of values that have a
|
|
303
|
+
token - and on an unwired project that swap points hundreds of literals at variables the browser
|
|
304
|
+
cannot resolve, so the declarations are dropped and the page changes (dono, 06/08: 210 values, in a
|
|
305
|
+
project where neither line was in place). The doctor itself now refuses in that state, and it is the
|
|
306
|
+
right place for the offer: it can see the wiring, and this run cannot see what they do after it ends.
|
|
252
307
|
|
|
253
308
|
---
|
|
254
309
|
|
|
@@ -261,6 +316,10 @@ short.
|
|
|
261
316
|
- **Never applies an optimisation without a frozen v1**, and never reports one without the way back.
|
|
262
317
|
- **Never claims parity between agents.** The post-write check exists in one of them. Saying
|
|
263
318
|
otherwise is the kind of sentence that ends a trial.
|
|
264
|
-
- **Never ends long.** If the last screen needs scrolling, the run failed at the last step.
|
|
319
|
+
- **Never ends long.** If the last screen needs scrolling, the run failed at the last step. The last
|
|
320
|
+
screen is \`summary\`'s output and nothing else - see section 6 for why that stopped being a rule and
|
|
321
|
+
became a command.
|
|
322
|
+
- **Never offers a fix that the wiring blocks.** A swap to tokens that do not reach the browser
|
|
323
|
+
changes the page, and it would be our command that changed it.
|
|
265
324
|
`;
|
|
266
325
|
export const INIT_SKILL_PATH = ".claude/skills/sui-init/SKILL.md";
|
package/package.json
CHANGED