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