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.
Files changed (51) hide show
  1. package/README.md +153 -0
  2. package/dist/connectors.js +76 -0
  3. package/dist/decisions.js +67 -0
  4. package/dist/embeddings.js +98 -0
  5. package/dist/evals.js +172 -0
  6. package/dist/finding-identity.js +229 -0
  7. package/dist/handbook.js +98 -0
  8. package/dist/paths.js +62 -0
  9. package/dist/profile.js +79 -0
  10. package/dist/reviews.js +99 -0
  11. package/dist/server.js +1136 -0
  12. package/dist/skills.js +57 -0
  13. package/dist/state.js +48 -0
  14. package/handbook-embeddings.json +1 -0
  15. package/package.json +46 -0
  16. package/skills/EVALS.md +122 -0
  17. package/skills/README.md +59 -0
  18. package/skills/_shared/delivery-modes.md +12 -0
  19. package/skills/_shared/execution-contract.md +43 -0
  20. package/skills/_shared/issue-schema.md +25 -0
  21. package/skills/_shared/severity-model.md +16 -0
  22. package/skills/analyze-figma/evals/cases.json +140 -0
  23. package/skills/analyze-url/evals/cases.json +136 -0
  24. package/skills/better-accessibility/SKILL.md +141 -0
  25. package/skills/better-accessibility/evals/cases.json +83 -0
  26. package/skills/better-components/SKILL.md +125 -0
  27. package/skills/better-components/evals/cases.json +77 -0
  28. package/skills/better-consistency/SKILL.md +125 -0
  29. package/skills/better-consistency/evals/cases.json +77 -0
  30. package/skills/better-forms/SKILL.md +135 -0
  31. package/skills/better-forms/evals/cases.json +78 -0
  32. package/skills/better-interface/SKILL.md +143 -0
  33. package/skills/better-interface/evals/cases.json +80 -0
  34. package/skills/better-layout/SKILL.md +132 -0
  35. package/skills/better-layout/evals/cases.json +81 -0
  36. package/skills/better-navigation/SKILL.md +170 -0
  37. package/skills/better-navigation/evals/cases.json +131 -0
  38. package/skills/better-performance/SKILL.md +195 -0
  39. package/skills/better-performance/evals/cases.json +114 -0
  40. package/skills/better-responsive/SKILL.md +123 -0
  41. package/skills/better-responsive/evals/cases.json +77 -0
  42. package/skills/better-states/SKILL.md +129 -0
  43. package/skills/better-states/evals/cases.json +78 -0
  44. package/skills/better-writing/SKILL.md +138 -0
  45. package/skills/better-writing/evals/cases.json +77 -0
  46. package/skills/review-ai-product/SKILL.md +231 -0
  47. package/skills/review-ai-product/evals/cases.json +120 -0
  48. package/skills/review-data-security/SKILL.md +210 -0
  49. package/skills/review-data-security/evals/cases.json +138 -0
  50. package/skills/review-figma-hygiene/SKILL.md +186 -0
  51. 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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }