designwithai-mcp 0.1.0
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/README.md +153 -0
- package/dist/connectors.js +76 -0
- package/dist/decisions.js +67 -0
- package/dist/embeddings.js +98 -0
- package/dist/evals.js +172 -0
- package/dist/finding-identity.js +229 -0
- package/dist/handbook.js +98 -0
- package/dist/paths.js +62 -0
- package/dist/profile.js +79 -0
- package/dist/reviews.js +99 -0
- package/dist/server.js +1136 -0
- package/dist/skills.js +57 -0
- package/dist/state.js +48 -0
- package/handbook-embeddings.json +1 -0
- package/package.json +46 -0
- package/skills/EVALS.md +122 -0
- package/skills/README.md +59 -0
- package/skills/_shared/delivery-modes.md +12 -0
- package/skills/_shared/execution-contract.md +43 -0
- package/skills/_shared/issue-schema.md +25 -0
- package/skills/_shared/severity-model.md +16 -0
- package/skills/analyze-figma/evals/cases.json +140 -0
- package/skills/analyze-url/evals/cases.json +136 -0
- package/skills/better-accessibility/SKILL.md +141 -0
- package/skills/better-accessibility/evals/cases.json +83 -0
- package/skills/better-components/SKILL.md +125 -0
- package/skills/better-components/evals/cases.json +77 -0
- package/skills/better-consistency/SKILL.md +125 -0
- package/skills/better-consistency/evals/cases.json +77 -0
- package/skills/better-forms/SKILL.md +135 -0
- package/skills/better-forms/evals/cases.json +78 -0
- package/skills/better-interface/SKILL.md +143 -0
- package/skills/better-interface/evals/cases.json +80 -0
- package/skills/better-layout/SKILL.md +132 -0
- package/skills/better-layout/evals/cases.json +81 -0
- package/skills/better-navigation/SKILL.md +170 -0
- package/skills/better-navigation/evals/cases.json +131 -0
- package/skills/better-performance/SKILL.md +195 -0
- package/skills/better-performance/evals/cases.json +114 -0
- package/skills/better-responsive/SKILL.md +123 -0
- package/skills/better-responsive/evals/cases.json +77 -0
- package/skills/better-states/SKILL.md +129 -0
- package/skills/better-states/evals/cases.json +78 -0
- package/skills/better-writing/SKILL.md +138 -0
- package/skills/better-writing/evals/cases.json +77 -0
- package/skills/review-ai-product/SKILL.md +231 -0
- package/skills/review-ai-product/evals/cases.json +120 -0
- package/skills/review-data-security/SKILL.md +210 -0
- package/skills/review-data-security/evals/cases.json +138 -0
- package/skills/review-figma-hygiene/SKILL.md +186 -0
- package/skills/review-figma-hygiene/evals/cases.json +158 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L'IDENTITÉ D'UN CONSTAT — définition unique, trois consommateurs.
|
|
3
|
+
*
|
|
4
|
+
* Le problème que ce fichier existe pour régler : « ton produit monte » est la
|
|
5
|
+
* promesse centrale du produit, et elle reposait sur trois définitions
|
|
6
|
+
* concurrentes de « c'est le même constat ».
|
|
7
|
+
*
|
|
8
|
+
* - le banc d'évals comparait par recouvrement de mots de contenu ;
|
|
9
|
+
* - `compare_reviews` (MCP) par `localisation|catégorie` en chaîne exacte ;
|
|
10
|
+
* - `progressOf` (site) par `résumé@@localisation` en chaîne exacte.
|
|
11
|
+
*
|
|
12
|
+
* Les deux dernières supposent une formulation stable. Or les constats sont
|
|
13
|
+
* écrits par un modèle, qui reformule à chaque passage. Mesuré sur deux passes
|
|
14
|
+
* réelles de designwithai.fr : « 3 résolus, 3 nouveaux, 0 persistant », alors
|
|
15
|
+
* qu'un des trois problèmes était rigoureusement le même, dit autrement. Un
|
|
16
|
+
* chiffre faux et confiant est pire qu'un chiffre absent — c'est précisément ce
|
|
17
|
+
* que ce produit reproche aux autres.
|
|
18
|
+
*
|
|
19
|
+
* La définition retenue est celle qui avait déjà été durcie au banc : le
|
|
20
|
+
* recouvrement de MOTS DE CONTENU (mots vides écartés) avec un plancher de deux
|
|
21
|
+
* mots partagés. Elle vit ici, et ici seulement.
|
|
22
|
+
*
|
|
23
|
+
* CONTRAINTE À TENIR : ce fichier ne dépend de rien. Le site l'importe dans un
|
|
24
|
+
* bundle navigateur ; la moindre importation Node (`node:fs` et compagnie, comme
|
|
25
|
+
* dans `handbook.ts`) le rendrait inutilisable de ce côté et ramènerait une
|
|
26
|
+
* deuxième définition par la fenêtre.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Découpe en mots, sans accents ni casse.
|
|
30
|
+
*
|
|
31
|
+
* Volontairement distinct du `tokenize` de `handbook.ts` : celui-là pilote le
|
|
32
|
+
* classement de la recherche, qu'on ne touche pas. Deux fonctions identiques
|
|
33
|
+
* aujourd'hui, deux responsabilités différentes — les mêler ferait qu'un
|
|
34
|
+
* réglage de pertinence de recherche déplacerait silencieusement les chiffres
|
|
35
|
+
* de progression.
|
|
36
|
+
*/
|
|
37
|
+
export function tokenize(s) {
|
|
38
|
+
return (s
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.normalize("NFD")
|
|
41
|
+
.replace(/[̀-ͯ]/g, "")
|
|
42
|
+
.match(/[a-z0-9]+/g) ?? []);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Mots vides FR + EN. Sans ce filtre, « revoir le layout de la page » et
|
|
46
|
+
* « libellé perfectible dans le pied de page » se recouvrent à 0,67 par les
|
|
47
|
+
* seuls mots outils.
|
|
48
|
+
*/
|
|
49
|
+
export const STOPWORDS = new Set([
|
|
50
|
+
// FR
|
|
51
|
+
"le", "la", "les", "un", "une", "des", "du", "de", "et", "ou", "au", "aux", "en", "dans",
|
|
52
|
+
"sur", "pour", "par", "ce", "cet", "cette", "ces", "son", "sa", "ses", "leur", "leurs",
|
|
53
|
+
"que", "qui", "quoi", "dont", "ne", "pas", "plus", "est", "sont", "etre", "ete", "avec",
|
|
54
|
+
"sans", "comme", "mais", "donc", "alors", "ainsi", "aussi", "tres", "tout", "tous",
|
|
55
|
+
"toute", "toutes", "entre", "vers", "chez", "apres", "avant", "lors", "il", "elle", "ils",
|
|
56
|
+
"elles", "nous", "vous", "on", "se",
|
|
57
|
+
// EN
|
|
58
|
+
"the", "an", "of", "to", "in", "on", "for", "and", "or", "is", "are", "was", "were", "be",
|
|
59
|
+
"been", "being", "with", "without", "from", "that", "this", "these", "those", "it", "its",
|
|
60
|
+
"as", "at", "by", "not", "no", "any", "all", "can", "has", "have", "had", "will", "would",
|
|
61
|
+
"should",
|
|
62
|
+
]);
|
|
63
|
+
/** Tokens porteurs de sens : hors mots vides, et plus longs que 2 caractères. */
|
|
64
|
+
export function contentTokens(s) {
|
|
65
|
+
return tokenize(s).filter((t) => t.length > 2 && !STOPWORDS.has(t));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Recouvrement de `phrase` dans `text`, en mots de contenu : le ratio, le nombre
|
|
69
|
+
* de mots réellement partagés, et la taille de la phrase.
|
|
70
|
+
*/
|
|
71
|
+
export function overlapStats(phrase, text) {
|
|
72
|
+
let p = contentTokens(phrase);
|
|
73
|
+
let t = contentTokens(text);
|
|
74
|
+
if (p.length === 0) {
|
|
75
|
+
// Phrase entièrement faite de mots outils : repli sur les tokens bruts, pour
|
|
76
|
+
// ne pas rendre impossible à matcher une phrase courte mais légitime.
|
|
77
|
+
p = tokenize(phrase);
|
|
78
|
+
t = tokenize(text);
|
|
79
|
+
}
|
|
80
|
+
if (p.length === 0)
|
|
81
|
+
return { ratio: 0, shared: 0, size: 0 };
|
|
82
|
+
const set = new Set(t);
|
|
83
|
+
const shared = p.filter((tok) => set.has(tok)).length;
|
|
84
|
+
return { ratio: shared / p.length, shared, size: p.length };
|
|
85
|
+
}
|
|
86
|
+
/** Au moins DEUX mots de contenu partagés, dès lors que la phrase en compte deux. */
|
|
87
|
+
export const MIN_SHARED = 2;
|
|
88
|
+
/**
|
|
89
|
+
* CONTENANCE — « cette phrase attendue se retrouve-t-elle dans ce constat ? »
|
|
90
|
+
*
|
|
91
|
+
* L'opération du banc d'évals : une phrase de référence contre le texte d'un
|
|
92
|
+
* constat. Asymétrique par nature, d'où le ratio rapporté à la seule phrase.
|
|
93
|
+
*/
|
|
94
|
+
export function matches(phrase, text, threshold = 0.5) {
|
|
95
|
+
const { ratio, shared, size } = overlapStats(phrase, text);
|
|
96
|
+
return ratio >= threshold && (size < MIN_SHARED || shared >= MIN_SHARED);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Le texte qui identifie un constat : OÙ, DE QUOI, et QUOI.
|
|
100
|
+
*
|
|
101
|
+
* La correction proposée est délibérément exclue : c'est le champ que le modèle
|
|
102
|
+
* reformule le plus, et deux formulations du même remède ne font pas deux
|
|
103
|
+
* problèmes différents.
|
|
104
|
+
*/
|
|
105
|
+
export function identityText(f) {
|
|
106
|
+
return [f.location, f.category, f.summary].filter(Boolean).join(" ");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* ÉQUIVALENCE — « ces deux constats parlent-ils du même problème ? »
|
|
110
|
+
*
|
|
111
|
+
* Symétrique, contrairement à `matches` : aucun des deux constats n'est la
|
|
112
|
+
* référence de l'autre. D'où le coefficient de Dice, qui rapporte les mots
|
|
113
|
+
* partagés à la taille des DEUX textes — un constat verbeux ne peut donc pas
|
|
114
|
+
* avaler un constat bref au seul motif qu'il le contient.
|
|
115
|
+
*/
|
|
116
|
+
export function compareFindings(a, b) {
|
|
117
|
+
const ta = contentTokens(identityText(a));
|
|
118
|
+
const tb = contentTokens(identityText(b));
|
|
119
|
+
// Deux constats trop courts pour être jugés au recouvrement : on retombe sur
|
|
120
|
+
// l'égalité stricte, plutôt que de faire dire n'importe quoi à un ratio calculé
|
|
121
|
+
// sur un ou deux mots.
|
|
122
|
+
if (ta.length < MIN_SHARED || tb.length < MIN_SHARED) {
|
|
123
|
+
const same = identityText(a).trim().toLowerCase() === identityText(b).trim().toLowerCase();
|
|
124
|
+
return { dice: same ? 1 : 0, shared: same ? Math.max(ta.length, tb.length) : 0 };
|
|
125
|
+
}
|
|
126
|
+
const setA = new Set(ta);
|
|
127
|
+
const setB = new Set(tb);
|
|
128
|
+
let shared = 0;
|
|
129
|
+
for (const token of setA)
|
|
130
|
+
if (setB.has(token))
|
|
131
|
+
shared++;
|
|
132
|
+
return { dice: (2 * shared) / (setA.size + setB.size), shared };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Le seuil d'équivalence, calibré sur des passes réelles — pas choisi au jugé.
|
|
136
|
+
*
|
|
137
|
+
* Mesures du 17 août 2026, deux passes de designwithai.fr. Problèmes RÉELLEMENT
|
|
138
|
+
* identiques : 0,37 · 0,38 · 0,55. Problèmes distincts : 0,00 · 0,04 · 0,05 ·
|
|
139
|
+
* 0,07 · 0,11 · 0,18 · 0,25 · 0,27. La frontière est donc entre 0,27 et 0,37 —
|
|
140
|
+
* 0,34 s'y place, et chacun de ces cas est épinglé par un test.
|
|
141
|
+
*
|
|
142
|
+
* Un premier réglage à 0,50, calibré sur une seule paire, laissait passer pour
|
|
143
|
+
* « résolu puis nouveau » des défauts manifestement identiques. La leçon vaut
|
|
144
|
+
* d'être écrite : un seuil se mesure sur des cas, il ne se devine pas.
|
|
145
|
+
*/
|
|
146
|
+
export const SAME_THRESHOLD = 0.34;
|
|
147
|
+
/**
|
|
148
|
+
* Reconnaissance par la LOCALISATION.
|
|
149
|
+
*
|
|
150
|
+
* Le cas qui a fait changer d'avis : deux passes décrivant le même bouton, à la
|
|
151
|
+
* même localisation (« Bouton « Fermer ⎋ » ») et dans la même catégorie
|
|
152
|
+
* (« champs »), ne se recouvraient qu'à 0,38 — parce que leurs résumés étaient
|
|
153
|
+
* rédigés tout autrement.
|
|
154
|
+
*
|
|
155
|
+
* Un constat s'identifie d'abord par OÙ il se trouve et DE QUEL TYPE il est ; le
|
|
156
|
+
* résumé est la partie que le modèle réécrit le plus. Quand le lieu et le type
|
|
157
|
+
* concordent franchement, c'est le même constat, quoi qu'en dise la prose.
|
|
158
|
+
*/
|
|
159
|
+
const LOCATION_THRESHOLD = 0.6;
|
|
160
|
+
function sameLocationAndKind(a, b) {
|
|
161
|
+
const ca = (a.category ?? "").trim().toLowerCase();
|
|
162
|
+
const cb = (b.category ?? "").trim().toLowerCase();
|
|
163
|
+
if (!ca || ca !== cb)
|
|
164
|
+
return false;
|
|
165
|
+
const la = contentTokens(a.location ?? "");
|
|
166
|
+
const lb = contentTokens(b.location ?? "");
|
|
167
|
+
if (la.length === 0 || lb.length === 0)
|
|
168
|
+
return false;
|
|
169
|
+
const setA = new Set(la);
|
|
170
|
+
const setB = new Set(lb);
|
|
171
|
+
let shared = 0;
|
|
172
|
+
for (const token of setA)
|
|
173
|
+
if (setB.has(token))
|
|
174
|
+
shared++;
|
|
175
|
+
return (2 * shared) / (setA.size + setB.size) >= LOCATION_THRESHOLD && shared >= MIN_SHARED;
|
|
176
|
+
}
|
|
177
|
+
export function sameFinding(a, b) {
|
|
178
|
+
if (sameLocationAndKind(a, b))
|
|
179
|
+
return true;
|
|
180
|
+
const { dice, shared } = compareFindings(a, b);
|
|
181
|
+
return dice >= SAME_THRESHOLD && shared >= MIN_SHARED;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* La comparaison de deux passes — L'ÉTAPE 5 DE LA BOUCLE.
|
|
185
|
+
*
|
|
186
|
+
* Appariement glouton au meilleur score : chaque constat de la passe récente
|
|
187
|
+
* prend le constat le plus proche encore libre dans la passe précédente. Sans
|
|
188
|
+
* cette exclusivité, un constat un peu générique s'apparierait à trois anciens
|
|
189
|
+
* et l'on compterait deux « résolus » imaginaires.
|
|
190
|
+
*/
|
|
191
|
+
export function diffFindings(base, head) {
|
|
192
|
+
const paired = new Set();
|
|
193
|
+
const persisting = [];
|
|
194
|
+
const added = [];
|
|
195
|
+
/* Force d'un appariement, 0 s'il n'a pas lieu d'être. Une concordance de lieu
|
|
196
|
+
et de type l'emporte sur un simple recouvrement de mots : c'est la preuve la
|
|
197
|
+
plus solide dont on dispose, elle doit donc gagner les ex aequo. */
|
|
198
|
+
const strength = (a, b) => {
|
|
199
|
+
if (!sameFinding(a, b))
|
|
200
|
+
return 0;
|
|
201
|
+
const { dice } = compareFindings(a, b);
|
|
202
|
+
return sameLocationAndKind(a, b) ? 1 + dice : dice;
|
|
203
|
+
};
|
|
204
|
+
for (const candidate of head) {
|
|
205
|
+
let bestIndex = -1;
|
|
206
|
+
let bestScore = 0;
|
|
207
|
+
for (let i = 0; i < base.length; i++) {
|
|
208
|
+
if (paired.has(i))
|
|
209
|
+
continue;
|
|
210
|
+
const score = strength(candidate, base[i]);
|
|
211
|
+
if (score > bestScore) {
|
|
212
|
+
bestScore = score;
|
|
213
|
+
bestIndex = i;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (bestIndex >= 0) {
|
|
217
|
+
paired.add(bestIndex);
|
|
218
|
+
persisting.push(candidate);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
added.push(candidate);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
resolved: base.filter((_, i) => !paired.has(i)),
|
|
226
|
+
added,
|
|
227
|
+
persisting,
|
|
228
|
+
};
|
|
229
|
+
}
|
package/dist/handbook.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chargement et interrogation de la source de vérité.
|
|
3
|
+
*
|
|
4
|
+
* Le serveur ne connaît PAS le contenu du Handbook : il lit `handbook.json`, le
|
|
5
|
+
* même artefact que le site sert. C'est ce qui garantit « une seule source,
|
|
6
|
+
* deux surfaces » — une modif de chapitre, régénérée au build, apparaît ici
|
|
7
|
+
* comme sur le site, sans copie divergente.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
const DEFAULT_URL = "https://designwithai.fr/handbook.json";
|
|
14
|
+
/** La racine du dépôt : deux niveaux au-dessus de `mcp/dist` (ou `mcp/src`). */
|
|
15
|
+
function repoRoot() {
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
return path.resolve(here, "..", "..");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Ordre de résolution, du plus explicite au plus distant :
|
|
21
|
+
* 1. `HANDBOOK_PATH` (fichier imposé — erreur s'il manque)
|
|
22
|
+
* 2. `out/handbook.json` puis `public/handbook.json` du dépôt
|
|
23
|
+
* 3. `HANDBOOK_URL`, sinon l'URL de prod
|
|
24
|
+
*/
|
|
25
|
+
export async function loadHandbook() {
|
|
26
|
+
const envPath = process.env.HANDBOOK_PATH;
|
|
27
|
+
if (envPath) {
|
|
28
|
+
if (!existsSync(envPath))
|
|
29
|
+
throw new Error(`HANDBOOK_PATH introuvable : ${envPath}`);
|
|
30
|
+
return { handbook: JSON.parse(await readFile(envPath, "utf8")), origin: envPath };
|
|
31
|
+
}
|
|
32
|
+
const root = repoRoot();
|
|
33
|
+
for (const p of [path.join(root, "out", "handbook.json"), path.join(root, "public", "handbook.json")]) {
|
|
34
|
+
if (existsSync(p)) {
|
|
35
|
+
return { handbook: JSON.parse(await readFile(p, "utf8")), origin: p };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const url = process.env.HANDBOOK_URL ?? DEFAULT_URL;
|
|
39
|
+
const res = await fetch(url);
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`Aucun handbook.json local et l'URL a échoué (${url} → ${res.status}). ` +
|
|
42
|
+
`Lance un build (\`STATIC_EXPORT=1 next build\`) ou pointe HANDBOOK_PATH sur un fichier.`);
|
|
43
|
+
}
|
|
44
|
+
return { handbook: (await res.json()), origin: url };
|
|
45
|
+
}
|
|
46
|
+
/** Repli d'accents + minuscules → tokens alphanumériques, pour le scoring. */
|
|
47
|
+
export function tokenize(s) {
|
|
48
|
+
return (s
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.normalize("NFD")
|
|
51
|
+
.replace(/[̀-ͯ]/g, "")
|
|
52
|
+
.match(/[a-z0-9]+/g) ?? []);
|
|
53
|
+
}
|
|
54
|
+
/** Score simple : titre fortement pondéré, corps plafonné pour éviter le biais de longueur. */
|
|
55
|
+
export function scoreUnit(unit, terms) {
|
|
56
|
+
if (terms.length === 0)
|
|
57
|
+
return 0;
|
|
58
|
+
const titleSet = new Set(tokenize(unit.title));
|
|
59
|
+
const body = tokenize(unit.text);
|
|
60
|
+
let score = 0;
|
|
61
|
+
for (const t of terms) {
|
|
62
|
+
if (titleSet.has(t))
|
|
63
|
+
score += 5;
|
|
64
|
+
let n = 0;
|
|
65
|
+
for (const w of body)
|
|
66
|
+
if (w === t)
|
|
67
|
+
n++;
|
|
68
|
+
score += Math.min(n, 5);
|
|
69
|
+
}
|
|
70
|
+
return score;
|
|
71
|
+
}
|
|
72
|
+
/** Un extrait autour de la première occurrence d'un terme, sinon la tête du texte. */
|
|
73
|
+
export function excerpt(text, rawTerms, radius = 220) {
|
|
74
|
+
const hay = text.toLowerCase();
|
|
75
|
+
let idx = -1;
|
|
76
|
+
for (const t of rawTerms) {
|
|
77
|
+
const i = hay.indexOf(t);
|
|
78
|
+
if (i >= 0 && (idx < 0 || i < idx))
|
|
79
|
+
idx = i;
|
|
80
|
+
}
|
|
81
|
+
const oneLine = (s) => s.replace(/\s+/g, " ").trim();
|
|
82
|
+
if (idx < 0)
|
|
83
|
+
return oneLine(text.slice(0, radius)) + (text.length > radius ? "…" : "");
|
|
84
|
+
const start = Math.max(0, idx - Math.floor(radius / 2));
|
|
85
|
+
const end = Math.min(text.length, idx + Math.floor(radius / 2));
|
|
86
|
+
return (start > 0 ? "…" : "") + oneLine(text.slice(start, end)) + (end < text.length ? "…" : "");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Même sémantique de filtrage que le site : un chapitre sans mode est commun
|
|
90
|
+
* (toujours inclus) ; un chapitre à modes n'est inclus que si le mode déclaré
|
|
91
|
+
* y figure. Les atomes ne portent pas de mode en V1 → jamais filtrés.
|
|
92
|
+
*/
|
|
93
|
+
export function chapterMatchesMode(unit, mode) {
|
|
94
|
+
if (!mode || unit.kind !== "chapter")
|
|
95
|
+
return true;
|
|
96
|
+
const modes = unit.delivery_modes ?? [];
|
|
97
|
+
return modes.length === 0 || modes.includes(mode);
|
|
98
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/**
|
|
4
|
+
* Les deux racines — et pourquoi il en faut deux.
|
|
5
|
+
*
|
|
6
|
+
* Tant que ce serveur ne se lançait que depuis le dépôt cloné, une seule racine
|
|
7
|
+
* suffisait : `dist/../..` désignait à la fois le code livré (les skills) et
|
|
8
|
+
* l'endroit où écrire (les revues, le profil). Distribué sur npm, ce raccourci
|
|
9
|
+
* casse en deux endroits à la fois : `dist/../..` tombe dans `node_modules`, on
|
|
10
|
+
* y chercherait des skills absents et on tenterait d'y écrire les revues de
|
|
11
|
+
* quelqu'un.
|
|
12
|
+
*
|
|
13
|
+
* Les deux natures se séparent proprement :
|
|
14
|
+
*
|
|
15
|
+
* PACKAGE — ce qui voyage AVEC le serveur et ne s'écrit jamais : la
|
|
16
|
+
* bibliothèque de skills, les embeddings du manuel. Résolu depuis le fichier
|
|
17
|
+
* courant, donc juste quel que soit le mode d'installation.
|
|
18
|
+
*
|
|
19
|
+
* WORKSPACE — ce qui appartient à L'UTILISATEUR et s'écrit : ses revues, ses
|
|
20
|
+
* décisions, son profil, son état. Le bon endroit est le projet depuis lequel
|
|
21
|
+
* il a lancé son éditeur — `process.cwd()`. C'est aussi ce qui préserve le
|
|
22
|
+
* comportement actuel : lancé depuis la racine du dépôt, `.designwithai/`
|
|
23
|
+
* reste exactement où il était.
|
|
24
|
+
*
|
|
25
|
+
* Les variables d'environnement (`SKILLS_PATH`, `DWAI_REVIEWS_PATH`…) restent
|
|
26
|
+
* prioritaires partout : elles seules permettent de dévier explicitement.
|
|
27
|
+
*/
|
|
28
|
+
/** La racine du paquet installé — `dist/..`, donc le dossier qui contient `dist`. */
|
|
29
|
+
export function packageRoot() {
|
|
30
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
return path.resolve(here, "..");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Le projet de l'utilisateur.
|
|
35
|
+
*
|
|
36
|
+
* `DWAI_WORKSPACE` permet de l'imposer — utile quand l'éditeur lance le serveur
|
|
37
|
+
* depuis un dossier qui n'est pas celui du projet.
|
|
38
|
+
*/
|
|
39
|
+
export function workspaceRoot() {
|
|
40
|
+
return process.env.DWAI_WORKSPACE ?? process.cwd();
|
|
41
|
+
}
|
|
42
|
+
/** Le dossier de travail local : revues, décisions, profil, état. */
|
|
43
|
+
export function workspaceDir(...segments) {
|
|
44
|
+
return path.join(workspaceRoot(), ".designwithai", ...segments);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* La bibliothèque de skills.
|
|
48
|
+
*
|
|
49
|
+
* Elle voyage dans le paquet (`prepack` la recopie depuis la racine du dépôt).
|
|
50
|
+
* En développement, le dossier n'est pas encore recopié : on retombe alors sur
|
|
51
|
+
* celui du dépôt, deux niveaux plus haut. L'ordre compte — le paquet d'abord,
|
|
52
|
+
* sinon une copie périmée du dépôt masquerait celle qu'on vient de livrer.
|
|
53
|
+
*/
|
|
54
|
+
export function skillsRoot() {
|
|
55
|
+
if (process.env.SKILLS_PATH)
|
|
56
|
+
return process.env.SKILLS_PATH;
|
|
57
|
+
return path.join(packageRoot(), "skills");
|
|
58
|
+
}
|
|
59
|
+
/** Le repli de développement : la racine du dépôt, quand on tourne depuis les sources. */
|
|
60
|
+
export function repoFallback() {
|
|
61
|
+
return path.resolve(packageRoot(), "..");
|
|
62
|
+
}
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Le profil projet, côté MCP.
|
|
3
|
+
*
|
|
4
|
+
* Le serveur local ne voit pas le localStorage du site. On lui donne donc le
|
|
5
|
+
* profil par un canal explicite — un fichier `.designwithai/profile.json` ou
|
|
6
|
+
* l'env `DWAI_PROFILE` — et l'outil `set_profile` permet à Claude de le poser
|
|
7
|
+
* en cours de session. Une fois connu, il contextualise les recos sans que
|
|
8
|
+
* l'utilisateur ait à redonner son mode à chaque appel.
|
|
9
|
+
*
|
|
10
|
+
* (Le pont complet avec le compte Supabase du site est l'étape D ; ici tout
|
|
11
|
+
* reste local, sans secret ni auth.)
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { workspaceDir } from "./paths.js";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
export function defaultProfilePath() {
|
|
18
|
+
return process.env.DWAI_PROFILE_PATH ?? workspaceDir("profile.json");
|
|
19
|
+
}
|
|
20
|
+
function candidates() {
|
|
21
|
+
return [
|
|
22
|
+
...(process.env.DWAI_PROFILE_PATH ? [process.env.DWAI_PROFILE_PATH] : []),
|
|
23
|
+
workspaceDir("profile.json"),
|
|
24
|
+
path.join(os.homedir(), ".designwithai", "profile.json"),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
export function loadProfile() {
|
|
28
|
+
if (process.env.DWAI_PROFILE) {
|
|
29
|
+
try {
|
|
30
|
+
return { profile: JSON.parse(process.env.DWAI_PROFILE), origin: "env:DWAI_PROFILE" };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* ignore un JSON d'env invalide */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const p of candidates()) {
|
|
37
|
+
if (existsSync(p)) {
|
|
38
|
+
try {
|
|
39
|
+
return { profile: JSON.parse(readFileSync(p, "utf8")), origin: p };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* fichier illisible : on continue */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { profile: null, origin: null };
|
|
47
|
+
}
|
|
48
|
+
export function saveProfile(profile) {
|
|
49
|
+
const p = defaultProfilePath();
|
|
50
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
51
|
+
writeFileSync(p, `${JSON.stringify(profile, null, 2)}\n`);
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
/** Accepte un mode canonique OU un libellé humain, et le résout. */
|
|
55
|
+
export function resolveDeliveryMode(v) {
|
|
56
|
+
if (!v)
|
|
57
|
+
return undefined;
|
|
58
|
+
const s = v.toLowerCase();
|
|
59
|
+
if (/(ai[\s-]?build|lovable|bolt|replit|v0)/.test(s))
|
|
60
|
+
return "ai-builder";
|
|
61
|
+
if (/hybr/.test(s))
|
|
62
|
+
return "hybride";
|
|
63
|
+
if (/(class|tradition|équipe|equipe|dév|dev)/.test(s))
|
|
64
|
+
return "classique";
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Une ligne de contexte lisible, pour annoter les sorties d'outils. */
|
|
68
|
+
export function profileSummary(p) {
|
|
69
|
+
if (!p)
|
|
70
|
+
return "";
|
|
71
|
+
const bits = [
|
|
72
|
+
p.name,
|
|
73
|
+
p.productType,
|
|
74
|
+
p.stage,
|
|
75
|
+
resolveDeliveryMode(p.deliveryMode),
|
|
76
|
+
p.dsStage ? `DS: ${p.dsStage}` : null,
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
return bits.join(" · ");
|
|
79
|
+
}
|
package/dist/reviews.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Les reviews comme artefacts suivis.
|
|
3
|
+
*
|
|
4
|
+
* Quand un skill (better-layout, better-interface…) produit un issue register,
|
|
5
|
+
* ce n'est pas un texte jetable : c'est un artefact. Ce module le stocke,
|
|
6
|
+
* l'horodate et permet de comparer deux passes sur le même artefact — donc de
|
|
7
|
+
* mesurer la RÉGRESSION. C'est ce qui transforme un audit ponctuel en process
|
|
8
|
+
* qualité. Stockage local, gitignored (`.designwithai/reviews/`).
|
|
9
|
+
*
|
|
10
|
+
* Le schéma d'un problème suit `skills/_shared/issue-schema.md`.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { workspaceDir } from "./paths.js";
|
|
15
|
+
import { diffFindings } from "./finding-identity.js";
|
|
16
|
+
export function reviewsDir() {
|
|
17
|
+
return process.env.DWAI_REVIEWS_PATH ?? workspaceDir("reviews");
|
|
18
|
+
}
|
|
19
|
+
function slug(s) {
|
|
20
|
+
return s
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.normalize("NFD")
|
|
23
|
+
.replace(/[̀-ͯ]/g, "")
|
|
24
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
25
|
+
.replace(/^-|-$/g, "")
|
|
26
|
+
.slice(0, 48);
|
|
27
|
+
}
|
|
28
|
+
/** Enregistre une review, en lui attribuant un id et un horodatage. */
|
|
29
|
+
export function saveReview(input, now) {
|
|
30
|
+
const stamp = now.toISOString();
|
|
31
|
+
// Millisecondes incluses (pas de slice à la seconde) : deux passes rapprochées
|
|
32
|
+
// ne doivent pas partager d'id. Dédup défensive si collision à la même ms.
|
|
33
|
+
const compact = stamp.replace(/[:.]/g, "-").replace("T", "_").replace(/Z$/, "");
|
|
34
|
+
const dir = reviewsDir();
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
let id = `${slug(input.skill)}-${compact}`;
|
|
37
|
+
for (let n = 2; existsSync(path.join(dir, `${id}.json`)); n++) {
|
|
38
|
+
id = `${slug(input.skill)}-${compact}-${n}`;
|
|
39
|
+
}
|
|
40
|
+
const review = { ...input, id, created_at: stamp };
|
|
41
|
+
writeFileSync(path.join(dir, `${review.id}.json`), `${JSON.stringify(review, null, 2)}\n`);
|
|
42
|
+
return review;
|
|
43
|
+
}
|
|
44
|
+
export function listReviews(filter) {
|
|
45
|
+
const dir = reviewsDir();
|
|
46
|
+
if (!existsSync(dir))
|
|
47
|
+
return [];
|
|
48
|
+
const all = [];
|
|
49
|
+
for (const f of readdirSync(dir)) {
|
|
50
|
+
if (!f.endsWith(".json"))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const r = JSON.parse(readFileSync(path.join(dir, f), "utf8"));
|
|
54
|
+
if (filter?.artifact_ref && r.artifact_ref !== filter.artifact_ref)
|
|
55
|
+
continue;
|
|
56
|
+
if (filter?.skill && r.skill !== filter.skill)
|
|
57
|
+
continue;
|
|
58
|
+
all.push(r);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* fichier corrompu : ignoré */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return all.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
65
|
+
}
|
|
66
|
+
export function getReview(id) {
|
|
67
|
+
const p = path.join(reviewsDir(), `${id}.json`);
|
|
68
|
+
if (!existsSync(p))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Met à jour le statut d'un finding dans une review stockée (étape verify_fix). */
|
|
78
|
+
export function updateFindingStatus(reviewId, index, status, verifiedBy) {
|
|
79
|
+
const r = getReview(reviewId);
|
|
80
|
+
if (!r || !r.findings[index])
|
|
81
|
+
return null;
|
|
82
|
+
r.findings[index] = { ...r.findings[index], status, verified_by: verifiedBy };
|
|
83
|
+
writeFileSync(path.join(reviewsDir(), `${r.id}.json`), `${JSON.stringify(r, null, 2)}\n`);
|
|
84
|
+
return r.findings[index];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Compare deux reviews (base = ancienne, head = récente).
|
|
88
|
+
*
|
|
89
|
+
* L'identité d'un constat vient de `finding-identity.ts`, partagée avec le banc
|
|
90
|
+
* d'évals et le cockpit du site. La version précédente comparait
|
|
91
|
+
* `localisation|catégorie` en chaîne exacte : elle tenait tant que les constats
|
|
92
|
+
* étaient écrits à la main, elle ne tient plus dès qu'un modèle les reformule
|
|
93
|
+
* d'une passe à l'autre — deux phrases pour le même défaut comptaient alors pour
|
|
94
|
+
* un résolu ET un nouveau.
|
|
95
|
+
*/
|
|
96
|
+
export function compareReviews(base, head) {
|
|
97
|
+
const { resolved, added, persisting } = diffFindings(base.findings, head.findings);
|
|
98
|
+
return { base, head, resolved, added, persisting };
|
|
99
|
+
}
|