designwithai-mcp 0.6.0 → 0.12.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 (62) hide show
  1. package/README.md +104 -1
  2. package/dist/annotation-schema.js +277 -0
  3. package/dist/annotations-store.js +80 -0
  4. package/dist/autonomy-matrix.js +195 -0
  5. package/dist/craft-measures.js +720 -0
  6. package/dist/craft-report.js +279 -0
  7. package/dist/craft-rules.js +200 -0
  8. package/dist/ds-diff3.js +510 -0
  9. package/dist/ds-inventory.js +404 -3
  10. package/dist/finding-identity.js +94 -5
  11. package/dist/framing-schema.js +239 -0
  12. package/dist/framings-store.js +136 -0
  13. package/dist/inventories-store.js +85 -0
  14. package/dist/inventory-schema.js +471 -0
  15. package/dist/name-normalize.js +341 -0
  16. package/dist/producer-evals.js +89 -0
  17. package/dist/reviews.js +14 -2
  18. package/dist/server.js +849 -30
  19. package/dist/source-of-truth.js +176 -0
  20. package/dist/truth-store.js +181 -0
  21. package/dist/verify-target.js +60 -0
  22. package/package.json +2 -2
  23. package/skills/EVALS.md +77 -1
  24. package/skills/_shared/annotations.md +55 -0
  25. package/skills/_shared/frontieres-agentiques.md +87 -0
  26. package/skills/_shared/issue-schema.md +5 -1
  27. package/skills/_shared/mesures.md +48 -0
  28. package/skills/_shared/producteurs.md +69 -0
  29. package/skills/better-agent-recovery/SKILL.md +95 -0
  30. package/skills/better-agent-recovery/evals/cases.json +107 -0
  31. package/skills/better-agent-trust/SKILL.md +102 -0
  32. package/skills/better-agent-trust/evals/cases.json +107 -0
  33. package/skills/better-agent-visibility/SKILL.md +111 -0
  34. package/skills/better-agent-visibility/evals/cases.json +106 -0
  35. package/skills/better-consistency/SKILL.md +15 -0
  36. package/skills/better-consistency/evals/cases.json +19 -0
  37. package/skills/better-interface/SKILL.md +31 -0
  38. package/skills/better-layout/SKILL.md +31 -0
  39. package/skills/better-layout/evals/cases.json +44 -0
  40. package/skills/better-responsive/SKILL.md +15 -0
  41. package/skills/better-states/SKILL.md +33 -0
  42. package/skills/better-writing/SKILL.md +26 -0
  43. package/skills/design-agent-experience/SKILL.md +154 -0
  44. package/skills/design-agent-experience/evals/cases.json +120 -0
  45. package/skills/frame-feature/SKILL.md +153 -0
  46. package/skills/frame-feature/evals/cases.json +120 -0
  47. package/skills/prepare-user-test/SKILL.md +161 -0
  48. package/skills/prepare-user-test/evals/cases.json +120 -0
  49. package/skills/prototype-feature/SKILL.md +170 -0
  50. package/skills/prototype-feature/evals/cases.json +141 -0
  51. package/skills/review-agent-capabilities/SKILL.md +96 -0
  52. package/skills/review-agent-capabilities/evals/cases.json +108 -0
  53. package/skills/review-agent-control/SKILL.md +113 -0
  54. package/skills/review-agent-control/evals/cases.json +130 -0
  55. package/skills/review-agent-tools/SKILL.md +101 -0
  56. package/skills/review-agent-tools/evals/cases.json +107 -0
  57. package/skills/review-contract-coverage/SKILL.md +160 -0
  58. package/skills/review-contract-coverage/evals/cases.json +128 -0
  59. package/skills/review-data-security/SKILL.md +9 -0
  60. package/skills/review-feature-framing/SKILL.md +8 -0
  61. package/skills/synthesize-user-feedback/SKILL.md +168 -0
  62. package/skills/synthesize-user-feedback/evals/cases.json +107 -0
package/README.md CHANGED
@@ -107,11 +107,22 @@ règles, anti-patterns, prompts, checklists, scorecards), en lecture.
107
107
  | `clear_gate` / `reopen_gate` / `gate_status` | Boucle d'état des gates (ferme un gate avec preuve) | `clear_gate` **refuse sans artefact** |
108
108
  | `audit_project` | Flow bout-en-bout : orchestrateur + profil + ancrage cité + traçage | **Refuse sans artefact** |
109
109
  | `list_eval_cases` / `get_eval_case` / `score_review` / `skill_scorecard` | Banc d'éval : prouve la qualité d'un skill (détection, périmètre, refus) | golden jamais révélée (run aveugle) |
110
- | `verify_fix` | Vérifie qu'une reco a été appliquée, critère par critère | **Refuse sans artefact/critères** |
110
+ | `verify_fix` | Vérifie qu'une reco a été appliquée, critère par critère — sur l'élément POINTÉ quand le constat porte une coordonnée | **Refuse sans artefact/critères** ; « élément non retrouvé » n'est JAMAIS « corrigé » |
111
111
  | `generate_decision_record` | Décision traçable (ADR) persistée | — |
112
112
  | `push_audit` | Envoie une review vers le compte designwithai (cockpit multi-appareils) | **Refuse sans `DWAI_TOKEN`** ; jeton révocable, jamais de clé forte |
113
113
  | `next_gate` | Prochain gate conscient des PRÉRÉQUIS (graphe) | ne propose que les gates débloqués |
114
114
  | `connect_repo` | Lit un dépôt local (stack, composants, tokens, sources) | distant = via les MCP dédiés |
115
+ | `inventory_ds` | Inventorie le design system d'un dépôt — valeurs, composants, états déclarés — et range DEUX faces : le code et ses fiches | lecture déterministe ; ce qui n'est pas compris est nommé, jamais deviné |
116
+ | `submit_inventory` | Reçoit un inventaire produit ailleurs (maquette Figma, fiches) au format commun | **refuse motivé** ; un inventaire partiel sans liste de ce qui n'a pas été lu est rejeté |
117
+ | `declare_source_of_truth` | Déclare qui fait foi, couche par couche (valeurs / états / rendu) | ne devine JAMAIS une source ; propose l'ADR, ne l'écrit pas |
118
+ | `diff_inventories` | Compare les trois faces : ce qui diverge, ce qui manque, et dans quel sens | une absence n'est un constat que si la face d'en face est lue entièrement |
119
+ | `save_framing` | Écrit un dossier de cadrage dans le projet, au format que `framing_criteria` relit | refuse la FORME, jamais le contenu ; le **verdict** ne s'y pose qu'après passage devant le juge |
120
+ | `list_framings` | Les cadrages du projet et leur verdict — ou son absence, dite telle quelle | `judged_only` ne rend que ceux qui ont passé leur juge |
121
+ | `submit_annotations` | Reçoit un lot d'éléments POINTÉS dans une app qui tourne (route, sélecteur, note) | normaliseur tolérant ; ce qui n'est pas compris est GARDÉ, ce qui n'est pas rangeable est NOMMÉ |
122
+ | `craft_report` | Mesure les règles de l'art : espacements, tailles, couleurs, rayons, bascules, cibles | **déterministe, zéro modèle** ; dit toujours contre quoi il a mesuré |
123
+ | `list_craft_rules` | Le référentiel : chaque règle, sa mesure, sa tolérance, ses exceptions, sa source | une règle non calculable n'y entre pas |
124
+ | `declare_source_of_truth` *(étendu)* | Déclare aussi le SYSTÈME : base d'espacement, ratio typo, bascules, plancher de cible | ce qui est déclaré fait foi — la convention ne passe jamais devant |
125
+ | `framing_criteria` *(étendu)* | Les critères d'acceptation à vérifier, **projet d'abord**, compte ensuite | la source lue est nommée ; un dossier jamais jugé le dit |
115
126
 
116
127
  **Recherche sémantique** (optionnelle, sans clé) : `npm run embed` pré-calcule des embeddings locaux (modèle multilingue, ONNX) → `handbook-embeddings.json`. Présent, `search_handbook` devient hybride (mots-clés + sens) ; absent, il retombe sur les mots-clés seuls. À relancer après chaque régénération de `handbook.json`.
117
128
 
@@ -120,6 +131,98 @@ Le profil est chargé au démarrage depuis `.designwithai/profile.json` (ou `DWA
120
131
  Tous acceptent `delivery_mode` (`ai-builder` / `classique` / `hybride`) et
121
132
  filtrent comme le fait le site.
122
133
 
134
+ ### L'arbitre à trois faces
135
+
136
+ Le design system existe en trois exemplaires qui se contredisent : la maquette,
137
+ le code, les fiches de composant. Ce serveur ne se connecte à aucun des trois —
138
+ il REÇOIT des inventaires et rend des jugements. Aucun secret n'y transite.
139
+
140
+ Un seul format normalisé pour les trois faces (`src/inventory-schema.ts`), et une
141
+ règle qui commande tout le reste : **un inventaire qui se déclare partiel sans
142
+ dire ce qu'il n'a pas lu est refusé à l'entrée.** Sans cette liste, « absent du
143
+ code » et « pas su lire » deviennent indiscernables, et le comparateur se met à
144
+ produire des fautes qui n'existent pas.
145
+
146
+ Dans l'ordre : `inventory_ds` (code + fiches) → `submit_inventory` (maquette,
147
+ depuis le MCP Figma) → `declare_source_of_truth` (facultatif) →
148
+ `diff_inventories`. Sans déclaration, la comparaison reste symétrique et le dit ;
149
+ avec, elle oriente — « le code s'est éloigné de la maquette sur 7 valeurs de
150
+ style » plutôt que « 7 divergences ».
151
+
152
+ Les inventaires sont rangés dans `.designwithai/inventories/` (surchargeable par
153
+ `DWAI_INVENTORIES_PATH`), les déclarations dans `.designwithai/source-of-truth.json`
154
+ (`DWAI_TRUTH_PATH`).
155
+
156
+ ### La chaîne d'une feature, dans l'éditeur
157
+
158
+ Le PM dit « j'ai une idée » et la chaîne l'emmène : cadrage jugé → impact chiffré
159
+ → prototype dans le vrai design system → protocole de test → synthèse confrontée
160
+ aux critères → décision tracée.
161
+
162
+ Trois skills **produisent**, et c'est là qu'est la discipline : **la sortie d'un
163
+ producteur est soumise à son juge avant d'être inscrite au registre.** Un skill
164
+ qui génère puis s'admire produit du texte plausible — c'est ce que le prompt
165
+ gratuit fait déjà. La règle est écrite dans `skills/_shared/producteurs.md`.
166
+
167
+ | Producteur | Juge apparié |
168
+ |---|---|
169
+ | `frame-feature` | `review-feature-framing` |
170
+ | `prototype-feature` | `better-states` + `better-interface` |
171
+ | `prepare-user-test` | *(aucun — sa grille de refus et son corpus le tiennent)* |
172
+
173
+ `synthesize-user-feedback` n'est pas un producteur : c'est le juge des notes de
174
+ test, qui les confronte aux critères du cadrage.
175
+
176
+ Les cadrages vivent dans `.designwithai/framings/` (`DWAI_FRAMINGS_PATH`), les
177
+ prototypes dans `.designwithai/prototypes/`.
178
+
179
+ ### Le pointage — juger sur des coordonnées
180
+
181
+ On ne construit pas d'outil d'annotation : d'autres le font (Agentation,
182
+ sv-agentation…). On définit ce qu'on sait en RECEVOIR — route, sélecteur, note —
183
+ comme pour les inventaires Figma et Storybook.
184
+
185
+ Trois gains, et le troisième est le moins évident :
186
+
187
+ 1. un juge travaille sur une **coordonnée** au lieu d'une description ;
188
+ 2. deux passes s'apparient dessus : « ton produit monte » cesse de dépendre de
189
+ la prose d'un modèle ;
190
+ 3. `verify_fix` va regarder **cet** élément, sur **cette** route.
191
+
192
+ **Et la règle qui protège tout le reste :** un sélecteur qu'on ne retrouve plus
193
+ n'est pas un défaut corrigé. Un renommage de classe CSS ferait sinon passer tous
194
+ les constats ancrés en « réglé » — le rapport le plus faux et le plus crédible
195
+ que ce produit puisse rendre. La garantie est un calcul (`verify-target.ts`),
196
+ pas une intention, et `diffFindings` range ces cas dans `deplaces` : ni réglés,
197
+ ni nouveaux.
198
+
199
+ Les lots vivent dans `.designwithai/annotations/` (`DWAI_ANNOTATIONS_PATH`).
200
+ Un constat sans ancre suit exactement le chemin d'avant.
201
+
202
+ ### Les règles de l'art, mesurées et non récitées
203
+
204
+ Les conventions du métier — base 8, échelles à ratio, plages de bascule — entrent
205
+ dans le produit **codifiées**, pas en dogme dans un prompt. Chaque règle est un
206
+ objet (`craft-rules.ts`) : un identifiant stable, la fonction qui la calcule
207
+ (`craft-measures.ts`, pur et sans modèle), sa tolérance, ses exceptions, et le
208
+ chapitre du manuel qui la fonde — vérifié contre le manuel par un test.
209
+
210
+ **Une règle qu'aucune fonction ne peut calculer n'entre pas dans le
211
+ référentiel.** Elle reste dans le `SKILL.md` du juge concerné, où elle a
212
+ toujours vécu.
213
+
214
+ Et la préséance, qui empêche le référentiel de devenir un dogme :
215
+
216
+ 1. **le système DÉCLARÉ fait foi.** Une base de 5 pixels déclarée et tenue n'est
217
+ pas un défaut de grille ;
218
+ 2. à défaut, **la cohérence interne** mesurée sur le code ;
219
+ 3. à défaut de tout, **la convention** — annoncée comme telle dans la phrase.
220
+
221
+ Un rapport dit toujours contre quoi il a mesuré. `better-layout`,
222
+ `better-responsive`, `better-interface` et `better-consistency` appellent
223
+ `craft_report` en premier : ils interprètent et priorisent des chiffres, ils ne
224
+ les refont pas à l'œil.
225
+
123
226
  **Prompts** — les 10 skills de `skills/` exposés comme prompts MCP (`/better-layout`,
124
227
  `/better-interface`, `/review-ai-product`…). Chaque prompt est *self-contained* :
125
228
  socle commun + corps du skill + invocation renseignée par les arguments
@@ -0,0 +1,277 @@
1
+ /**
2
+ * L'ANNOTATION — ce qu'on sait recevoir quand quelqu'un pointe un élément.
3
+ *
4
+ * On ne construit pas d'outil d'annotation. D'autres le font déjà — on clique
5
+ * un élément d'une application qui tourne, et on obtient un bloc structuré :
6
+ * la route, un sélecteur, une note. Ce fichier définit ce qu'on sait en faire,
7
+ * exactement comme `inventory-schema.ts` définit ce qu'on sait recevoir d'un
8
+ * inventaire Figma sans jamais ouvrir Figma.
9
+ *
10
+ * CE QUE ÇA CHANGE, ET C'EST TRIPLE :
11
+ * — un juge travaille sur des COORDONNÉES au lieu d'une description ;
12
+ * — deux passes s'apparient sur ces coordonnées, ce qui rend « ton produit
13
+ * monte » beaucoup moins dépendant de la prose d'un modèle ;
14
+ * — `verify_fix` va regarder CET élément-là, et pas « la page ».
15
+ *
16
+ * LE NORMALISEUR EST TOLÉRANT, ET C'EST DÉLIBÉRÉ. Chaque outil a sa forme —
17
+ * `url` ici, `route` là, `comment` d'un côté, `note` de l'autre. On extrait ce
18
+ * qu'on reconnaît, et **on garde tel quel ce qu'on ne comprend pas** dans
19
+ * `brut`. Jeter un champ inconnu, c'est perdre en silence une information que
20
+ * quelqu'un avait pris la peine de produire ; la garder sans l'interpréter ne
21
+ * coûte rien et laisse la porte ouverte.
22
+ *
23
+ * Aucune dépendance : ce module peut partir dans un bundle navigateur.
24
+ */
25
+ export const ORIGINES = ["outil", "collage"];
26
+ /**
27
+ * Un plafond, pour que le refus soit un refus et pas un plantage.
28
+ *
29
+ * Cinq cents éléments pointés ne sont pas une session d'annotation : c'est un
30
+ * export accidentel. Le dire coûte moins cher que de le persister.
31
+ */
32
+ export const MAX_ANNOTATIONS = 500;
33
+ const estObjet = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
34
+ const chaine = (v) => typeof v === "string" && v.trim().length > 0;
35
+ /**
36
+ * Valide un lot, strictement, et MOTIVE son refus.
37
+ *
38
+ * Les trois champs obligatoires d'une annotation sont ceux sans lesquels elle ne
39
+ * sert à rien : sans route ni sélecteur, on ne sait pas où regarder ; sans note,
40
+ * on ne sait pas quoi regarder. Tout le reste est facultatif — y compris la
41
+ * capture, qui n'est jamais qu'un confort.
42
+ */
43
+ export function validerLot(brut) {
44
+ const erreurs = [];
45
+ if (!estObjet(brut))
46
+ return { ok: false, erreurs: ["Le lot doit être un objet."] };
47
+ const origine = brut.origine;
48
+ if (!chaine(origine) || !ORIGINES.includes(origine)) {
49
+ erreurs.push(`origine doit valoir ${ORIGINES.map((o) => `« ${o} »`).join(" ou ")}.`);
50
+ }
51
+ const annotations = [];
52
+ if (!Array.isArray(brut.annotations)) {
53
+ erreurs.push("annotations doit être une liste — une entrée par élément pointé.");
54
+ }
55
+ else if (brut.annotations.length === 0) {
56
+ erreurs.push("Le lot est vide : rien n'a été pointé, il n'y a rien à ranger.");
57
+ }
58
+ else if (brut.annotations.length > MAX_ANNOTATIONS) {
59
+ erreurs.push(`${brut.annotations.length} annotations : au-delà de ${MAX_ANNOTATIONS}, ce n'est plus une ` +
60
+ "session de pointage mais un export accidentel.");
61
+ }
62
+ else {
63
+ brut.annotations.forEach((a, i) => {
64
+ const ou = `annotations[${i}]`;
65
+ if (!estObjet(a)) {
66
+ erreurs.push(`${ou} doit être un objet.`);
67
+ return;
68
+ }
69
+ if (!chaine(a.route))
70
+ erreurs.push(`${ou}.route est obligatoire — sans elle, on ne sait pas sur quel écran regarder.`);
71
+ if (!chaine(a.selector))
72
+ erreurs.push(`${ou}.selector est obligatoire — sans lui, on ne sait pas quel élément regarder.`);
73
+ if (!chaine(a.note))
74
+ erreurs.push(`${ou}.note est obligatoire — un élément pointé sans rien à en dire n'est pas une annotation.`);
75
+ for (const cle of ["texte", "capture"]) {
76
+ if (a[cle] !== undefined && typeof a[cle] !== "string")
77
+ erreurs.push(`${ou}.${cle} doit être un texte.`);
78
+ }
79
+ if (a.dimensions !== undefined) {
80
+ const d = a.dimensions;
81
+ if (!estObjet(d) || typeof d.largeur !== "number" || typeof d.hauteur !== "number") {
82
+ erreurs.push(`${ou}.dimensions doit valoir { largeur, hauteur } en nombres.`);
83
+ }
84
+ }
85
+ if (a.brut !== undefined && !estObjet(a.brut))
86
+ erreurs.push(`${ou}.brut doit être un objet.`);
87
+ if (chaine(a.route) && chaine(a.selector) && chaine(a.note)) {
88
+ annotations.push({
89
+ route: a.route.trim(),
90
+ selector: a.selector.trim(),
91
+ note: a.note.trim(),
92
+ ...(chaine(a.texte) ? { texte: a.texte.trim() } : {}),
93
+ ...(chaine(a.capture) ? { capture: a.capture.trim() } : {}),
94
+ ...(estObjet(a.dimensions) &&
95
+ typeof a.dimensions.largeur === "number" &&
96
+ typeof a.dimensions.hauteur === "number"
97
+ ? { dimensions: { largeur: a.dimensions.largeur, hauteur: a.dimensions.hauteur } }
98
+ : {}),
99
+ ...(estObjet(a.brut) && Object.keys(a.brut).length > 0 ? { brut: a.brut } : {}),
100
+ });
101
+ }
102
+ });
103
+ }
104
+ if (erreurs.length > 0)
105
+ return { ok: false, erreurs };
106
+ return {
107
+ ok: true,
108
+ lot: {
109
+ at: chaine(brut.at) ? brut.at : "",
110
+ origine: origine,
111
+ ...(chaine(brut.outil) ? { outil: brut.outil.trim() } : {}),
112
+ ...(chaine(brut.product_ref) ? { product_ref: brut.product_ref.trim() } : {}),
113
+ annotations,
114
+ },
115
+ };
116
+ }
117
+ /* ─── Le normaliseur, tolérant ───────────────────────────────────────────── */
118
+ /**
119
+ * LES NOMS QU'ON RECONNAÎT, par ordre de préférence.
120
+ *
121
+ * La liste est courte et EXPLICITE : chaque entrée est une équivalence qu'on
122
+ * assume. Elle ne devine pas — un champ absent de cette table part dans `brut`,
123
+ * intact. Allonger la liste demande d'avoir vu un outil qui l'emploie.
124
+ */
125
+ export const ALIAS = {
126
+ route: ["route", "path", "pathname", "url", "page", "screen", "ecran"],
127
+ selector: ["selector", "cssSelector", "css", "xpath", "domSelector", "target", "element_selector"],
128
+ note: ["note", "comment", "commentaire", "message", "feedback", "remark", "description"],
129
+ texte: ["texte", "text", "textContent", "innerText", "elementText", "label"],
130
+ capture: ["capture", "screenshot", "screenshot_description", "image_description"],
131
+ };
132
+ const NORMALISES = new Set([
133
+ ...ALIAS.route, ...ALIAS.selector, ...ALIAS.note, ...ALIAS.texte, ...ALIAS.capture,
134
+ "dimensions", "rect", "size", "brut", "boundingRect",
135
+ ]);
136
+ function premier(o, cles) {
137
+ for (const c of cles) {
138
+ const v = o[c];
139
+ if (typeof v === "string" && v.trim().length > 0)
140
+ return v.trim();
141
+ }
142
+ return undefined;
143
+ }
144
+ /**
145
+ * UNE URL DEVIENT UNE ROUTE.
146
+ *
147
+ * Les outils livrent souvent `http://localhost:3000/rapport?tab=2#haut`. Ce qui
148
+ * identifie l'écran, c'est `/rapport` — le port change entre deux machines, la
149
+ * requête et l'ancre changent entre deux clics, et un appariement sur l'URL
150
+ * entière ne survivrait pas au premier changement d'environnement.
151
+ *
152
+ * Ce qui n'est pas une URL est rendu tel quel : une route est déjà une route.
153
+ */
154
+ export function routeDepuis(v) {
155
+ const t = v.trim();
156
+ const m = t.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+(\/[^?#]*)?/i);
157
+ if (m)
158
+ return m[1] && m[1].length > 0 ? m[1] : "/";
159
+ return t.split("?")[0].split("#")[0] || t;
160
+ }
161
+ function dimensionsDepuis(o) {
162
+ for (const cle of ["dimensions", "rect", "size", "boundingRect"]) {
163
+ const d = o[cle];
164
+ if (!estObjet(d))
165
+ continue;
166
+ const l = d.largeur ?? d.width ?? d.w;
167
+ const h = d.hauteur ?? d.height ?? d.h;
168
+ if (typeof l === "number" && typeof h === "number")
169
+ return { largeur: l, hauteur: h };
170
+ }
171
+ return undefined;
172
+ }
173
+ /**
174
+ * Normalise UNE entrée venue d'un outil quelconque.
175
+ *
176
+ * Deux niveaux : les champs de premier rang, puis ceux d'un objet `element` ou
177
+ * `target` imbriqué — la forme la plus répandue. Ce qui reste part dans `brut`,
178
+ * SANS être interprété.
179
+ */
180
+ export function normaliserAnnotation(brut) {
181
+ if (!estObjet(brut))
182
+ return null;
183
+ const imbrique = [brut.element, brut.target, brut.node].find(estObjet) ?? {};
184
+ const cherche = (cles) => premier(brut, cles) ?? premier(imbrique, cles);
185
+ const routeBrute = cherche(ALIAS.route);
186
+ const selector = cherche(ALIAS.selector);
187
+ const note = premier(brut, ALIAS.note);
188
+ if (!routeBrute || !selector || !note)
189
+ return null;
190
+ /* Le texte de l'élément ne doit JAMAIS être confondu avec la note : un `text`
191
+ de premier rang est ambigu, on ne le prend que s'il n'a pas déjà servi de
192
+ note, et on préfère toujours celui de l'objet imbriqué. */
193
+ const texte = premier(imbrique, ALIAS.texte) ?? (premier(brut, ALIAS.note) === premier(brut, ALIAS.texte) ? undefined : premier(brut, ALIAS.texte));
194
+ const restes = {};
195
+ for (const source of [brut, imbrique]) {
196
+ for (const [k, v] of Object.entries(source)) {
197
+ if (NORMALISES.has(k) || k === "element" || k === "target" || k === "node")
198
+ continue;
199
+ if (v === undefined || v === null)
200
+ continue;
201
+ restes[k] = v;
202
+ }
203
+ }
204
+ const dims = dimensionsDepuis(brut) ?? dimensionsDepuis(imbrique);
205
+ return {
206
+ route: routeDepuis(routeBrute),
207
+ selector,
208
+ note,
209
+ ...(texte ? { texte } : {}),
210
+ ...(cherche(ALIAS.capture) ? { capture: cherche(ALIAS.capture) } : {}),
211
+ ...(dims ? { dimensions: dims } : {}),
212
+ ...(Object.keys(restes).length > 0 ? { brut: restes } : {}),
213
+ };
214
+ }
215
+ /**
216
+ * Normalise un lot entier.
217
+ *
218
+ * Ce qui n'a pas pu être normalisé est NOMMÉ, pas jeté en silence. Une entrée
219
+ * sans sélecteur n'est pas une annotation — mais la personne qui l'a collée
220
+ * doit l'apprendre, sinon elle croira que ses cinq pointages sont rangés alors
221
+ * qu'il n'y en a que quatre.
222
+ */
223
+ export function normaliserLot(entrees, opts = {}) {
224
+ const annotations = [];
225
+ const ignorees = [];
226
+ entrees.forEach((e, i) => {
227
+ const a = normaliserAnnotation(e);
228
+ if (a) {
229
+ annotations.push(a);
230
+ return;
231
+ }
232
+ const o = estObjet(e) ? e : {};
233
+ const manque = [
234
+ premier(o, ALIAS.route) ? null : "route",
235
+ premier(o, ALIAS.selector) ? null : "sélecteur",
236
+ premier(o, ALIAS.note) ? null : "note",
237
+ ].filter(Boolean);
238
+ ignorees.push(`entrée ${i + 1} : ${manque.length ? `il manque ${manque.join(", ")}` : "forme non reconnue"}.`);
239
+ });
240
+ return {
241
+ lot: {
242
+ at: opts.at ?? "",
243
+ origine: opts.origine ?? "collage",
244
+ ...(opts.outil ? { outil: opts.outil } : {}),
245
+ ...(opts.productRef ? { product_ref: opts.productRef } : {}),
246
+ annotations,
247
+ },
248
+ ignorees,
249
+ };
250
+ }
251
+ /* ─── Le rendu ───────────────────────────────────────────────────────────── */
252
+ /** Un lot, rendu lisible. C'est ce que le juge reçoit et ce qu'il cite. */
253
+ export function rendreLot(lot) {
254
+ const l = [];
255
+ l.push(`${lot.annotations.length} endroit(s) pointé(s)` +
256
+ (lot.outil ? ` avec ${lot.outil}` : lot.origine === "collage" ? ", collés à la main" : "") +
257
+ (lot.at ? ` le ${lot.at.slice(0, 10)}` : "") +
258
+ ".");
259
+ l.push("");
260
+ lot.annotations.forEach((a, i) => {
261
+ l.push(`${i + 1}. ${a.route} · ${a.selector}`);
262
+ l.push(` « ${a.note} »`);
263
+ if (a.texte)
264
+ l.push(` texte de l'élément au moment du clic : « ${a.texte} »`);
265
+ if (a.dimensions)
266
+ l.push(` ${a.dimensions.largeur} × ${a.dimensions.hauteur}`);
267
+ if (a.capture)
268
+ l.push(` capture : ${a.capture}`);
269
+ if (a.brut)
270
+ l.push(` gardé sans être interprété : ${Object.keys(a.brut).join(", ")}`);
271
+ });
272
+ return l.join("\n");
273
+ }
274
+ /** Les routes couvertes par un lot, dédoublonnées et triées. */
275
+ export function routesDe(lot) {
276
+ return [...new Set(lot.annotations.map((a) => a.route))].sort();
277
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * OÙ ATTERRISSENT LES ÉLÉMENTS POINTÉS.
3
+ *
4
+ * `.designwithai/annotations/<horodatage>.json`, dans le projet de la personne,
5
+ * plus un `latest.json` qui pointe sur le dernier lot. Rien ne part sur le
6
+ * réseau : ce serveur reçoit un pointage et rend un jugement.
7
+ *
8
+ * POURQUOI ON GARDE L'HISTORIQUE. Un lot décrit une SESSION de pointage — « ce
9
+ * jour-là, ces trois endroits gênaient ». Le dernier écrase le précédent pour
10
+ * la lecture courante, mais « la gêne a changé d'endroit entre les deux
11
+ * passages » est exactement ce qu'on veut pouvoir dire, et écraser sans garder
12
+ * rendrait cette phrase invérifiable.
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { workspaceDir } from "./paths.js";
17
+ import { validerLot } from "./annotation-schema.js";
18
+ export function annotationsDir() {
19
+ return process.env.DWAI_ANNOTATIONS_PATH ?? workspaceDir("annotations");
20
+ }
21
+ /** `2026-08-20_1432` — lisible, triable, sans caractère interdit. */
22
+ export function horodatage(now) {
23
+ return now.toISOString().replace(/[:.]/g, "-").replace("T", "_").replace(/Z$/, "").slice(0, 16);
24
+ }
25
+ export function enregistrerLot(lot, now) {
26
+ const dir = annotationsDir();
27
+ mkdirSync(dir, { recursive: true });
28
+ /* La date est posée ICI si elle manque : un lot sans date ne permet pas de
29
+ dire lequel de deux pointages est le plus récent, et c'est la seule
30
+ question qu'on lui posera dans six mois. */
31
+ const complet = { ...lot, at: lot.at || now.toISOString() };
32
+ const chemin = path.join(dir, `${horodatage(now)}.json`);
33
+ const cheminDernier = path.join(dir, "latest.json");
34
+ const contenu = `${JSON.stringify(complet, null, 2)}\n`;
35
+ writeFileSync(chemin, contenu);
36
+ writeFileSync(cheminDernier, contenu);
37
+ return { lot: complet, chemin, cheminDernier };
38
+ }
39
+ /**
40
+ * Le dernier lot, ou `null`.
41
+ *
42
+ * Il repasse par la validation à la relecture : le fichier vit dans le projet,
43
+ * il peut avoir été édité à la main, et une annotation sans sélecteur ferait
44
+ * porter une ancre incomplète à un constat.
45
+ */
46
+ export function lireDernier() {
47
+ const f = path.join(annotationsDir(), "latest.json");
48
+ if (!existsSync(f))
49
+ return null;
50
+ try {
51
+ const v = validerLot(JSON.parse(readFileSync(f, "utf8")));
52
+ return v.ok ? v.lot : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** L'historique, du plus récent au plus ancien. Le pointeur `latest` exclu. */
59
+ export function historique() {
60
+ const dir = annotationsDir();
61
+ if (!existsSync(dir))
62
+ return [];
63
+ return readdirSync(dir)
64
+ .filter((f) => f.endsWith(".json") && f !== "latest.json")
65
+ .sort()
66
+ .reverse();
67
+ }
68
+ /**
69
+ * L'annotation qui porte cette coordonnée, dans le dernier lot.
70
+ *
71
+ * Sert à `verify_fix` : quand un constat ancré revient, on veut pouvoir citer
72
+ * ce que la personne avait écrit en pointant, et le texte que l'élément portait
73
+ * à ce moment-là — c'est le seul repli quand le sélecteur a bougé.
74
+ */
75
+ export function annotationDe(a, lot = lireDernier()) {
76
+ if (!lot)
77
+ return null;
78
+ const canon = (s) => s.toLowerCase().replace(/\s+/g, " ").replace(/\s*([>+~,])\s*/g, "$1").trim();
79
+ return (lot.annotations.find((x) => canon(x.route) === canon(a.route) && canon(x.selector) === canon(a.selector)) ?? null);
80
+ }