designwithai-mcp 0.5.0 → 0.10.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 (57) hide show
  1. package/README.md +77 -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/ds-diff3.js +510 -0
  6. package/dist/ds-inventory.js +147 -2
  7. package/dist/export-findings.js +142 -0
  8. package/dist/finding-identity.js +94 -5
  9. package/dist/framing-schema.js +239 -0
  10. package/dist/framings-store.js +136 -0
  11. package/dist/inventories-store.js +85 -0
  12. package/dist/inventory-schema.js +471 -0
  13. package/dist/name-normalize.js +341 -0
  14. package/dist/producer-evals.js +89 -0
  15. package/dist/reviews.js +14 -2
  16. package/dist/server.js +793 -32
  17. package/dist/severity.js +71 -0
  18. package/dist/source-of-truth.js +155 -0
  19. package/dist/truth-store.js +72 -0
  20. package/dist/verify-target.js +60 -0
  21. package/package.json +2 -2
  22. package/skills/EVALS.md +77 -1
  23. package/skills/_shared/annotations.md +55 -0
  24. package/skills/_shared/frontieres-agentiques.md +87 -0
  25. package/skills/_shared/issue-schema.md +5 -1
  26. package/skills/_shared/producteurs.md +69 -0
  27. package/skills/better-agent-recovery/SKILL.md +95 -0
  28. package/skills/better-agent-recovery/evals/cases.json +107 -0
  29. package/skills/better-agent-trust/SKILL.md +102 -0
  30. package/skills/better-agent-trust/evals/cases.json +107 -0
  31. package/skills/better-agent-visibility/SKILL.md +111 -0
  32. package/skills/better-agent-visibility/evals/cases.json +106 -0
  33. package/skills/better-interface/SKILL.md +19 -0
  34. package/skills/better-layout/SKILL.md +16 -0
  35. package/skills/better-states/SKILL.md +33 -0
  36. package/skills/better-writing/SKILL.md +26 -0
  37. package/skills/design-agent-experience/SKILL.md +154 -0
  38. package/skills/design-agent-experience/evals/cases.json +120 -0
  39. package/skills/frame-feature/SKILL.md +153 -0
  40. package/skills/frame-feature/evals/cases.json +120 -0
  41. package/skills/prepare-user-test/SKILL.md +161 -0
  42. package/skills/prepare-user-test/evals/cases.json +120 -0
  43. package/skills/prototype-feature/SKILL.md +170 -0
  44. package/skills/prototype-feature/evals/cases.json +141 -0
  45. package/skills/review-agent-capabilities/SKILL.md +96 -0
  46. package/skills/review-agent-capabilities/evals/cases.json +108 -0
  47. package/skills/review-agent-control/SKILL.md +113 -0
  48. package/skills/review-agent-control/evals/cases.json +130 -0
  49. package/skills/review-agent-tools/SKILL.md +101 -0
  50. package/skills/review-agent-tools/evals/cases.json +107 -0
  51. package/skills/review-ai-product/SKILL.md +3 -0
  52. package/skills/review-contract-coverage/SKILL.md +160 -0
  53. package/skills/review-contract-coverage/evals/cases.json +128 -0
  54. package/skills/review-data-security/SKILL.md +9 -0
  55. package/skills/review-feature-framing/SKILL.md +8 -0
  56. package/skills/synthesize-user-feedback/SKILL.md +168 -0
  57. package/skills/synthesize-user-feedback/evals/cases.json +107 -0
package/README.md CHANGED
@@ -107,11 +107,19 @@ 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
+ | `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
123
 
116
124
  **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
125
 
@@ -120,6 +128,74 @@ Le profil est chargé au démarrage depuis `.designwithai/profile.json` (ou `DWA
120
128
  Tous acceptent `delivery_mode` (`ai-builder` / `classique` / `hybride`) et
121
129
  filtrent comme le fait le site.
122
130
 
131
+ ### L'arbitre à trois faces
132
+
133
+ Le design system existe en trois exemplaires qui se contredisent : la maquette,
134
+ le code, les fiches de composant. Ce serveur ne se connecte à aucun des trois —
135
+ il REÇOIT des inventaires et rend des jugements. Aucun secret n'y transite.
136
+
137
+ Un seul format normalisé pour les trois faces (`src/inventory-schema.ts`), et une
138
+ règle qui commande tout le reste : **un inventaire qui se déclare partiel sans
139
+ dire ce qu'il n'a pas lu est refusé à l'entrée.** Sans cette liste, « absent du
140
+ code » et « pas su lire » deviennent indiscernables, et le comparateur se met à
141
+ produire des fautes qui n'existent pas.
142
+
143
+ Dans l'ordre : `inventory_ds` (code + fiches) → `submit_inventory` (maquette,
144
+ depuis le MCP Figma) → `declare_source_of_truth` (facultatif) →
145
+ `diff_inventories`. Sans déclaration, la comparaison reste symétrique et le dit ;
146
+ avec, elle oriente — « le code s'est éloigné de la maquette sur 7 valeurs de
147
+ style » plutôt que « 7 divergences ».
148
+
149
+ Les inventaires sont rangés dans `.designwithai/inventories/` (surchargeable par
150
+ `DWAI_INVENTORIES_PATH`), les déclarations dans `.designwithai/source-of-truth.json`
151
+ (`DWAI_TRUTH_PATH`).
152
+
153
+ ### La chaîne d'une feature, dans l'éditeur
154
+
155
+ Le PM dit « j'ai une idée » et la chaîne l'emmène : cadrage jugé → impact chiffré
156
+ → prototype dans le vrai design system → protocole de test → synthèse confrontée
157
+ aux critères → décision tracée.
158
+
159
+ Trois skills **produisent**, et c'est là qu'est la discipline : **la sortie d'un
160
+ producteur est soumise à son juge avant d'être inscrite au registre.** Un skill
161
+ qui génère puis s'admire produit du texte plausible — c'est ce que le prompt
162
+ gratuit fait déjà. La règle est écrite dans `skills/_shared/producteurs.md`.
163
+
164
+ | Producteur | Juge apparié |
165
+ |---|---|
166
+ | `frame-feature` | `review-feature-framing` |
167
+ | `prototype-feature` | `better-states` + `better-interface` |
168
+ | `prepare-user-test` | *(aucun — sa grille de refus et son corpus le tiennent)* |
169
+
170
+ `synthesize-user-feedback` n'est pas un producteur : c'est le juge des notes de
171
+ test, qui les confronte aux critères du cadrage.
172
+
173
+ Les cadrages vivent dans `.designwithai/framings/` (`DWAI_FRAMINGS_PATH`), les
174
+ prototypes dans `.designwithai/prototypes/`.
175
+
176
+ ### Le pointage — juger sur des coordonnées
177
+
178
+ On ne construit pas d'outil d'annotation : d'autres le font (Agentation,
179
+ sv-agentation…). On définit ce qu'on sait en RECEVOIR — route, sélecteur, note —
180
+ comme pour les inventaires Figma et Storybook.
181
+
182
+ Trois gains, et le troisième est le moins évident :
183
+
184
+ 1. un juge travaille sur une **coordonnée** au lieu d'une description ;
185
+ 2. deux passes s'apparient dessus : « ton produit monte » cesse de dépendre de
186
+ la prose d'un modèle ;
187
+ 3. `verify_fix` va regarder **cet** élément, sur **cette** route.
188
+
189
+ **Et la règle qui protège tout le reste :** un sélecteur qu'on ne retrouve plus
190
+ n'est pas un défaut corrigé. Un renommage de classe CSS ferait sinon passer tous
191
+ les constats ancrés en « réglé » — le rapport le plus faux et le plus crédible
192
+ que ce produit puisse rendre. La garantie est un calcul (`verify-target.ts`),
193
+ pas une intention, et `diffFindings` range ces cas dans `deplaces` : ni réglés,
194
+ ni nouveaux.
195
+
196
+ Les lots vivent dans `.designwithai/annotations/` (`DWAI_ANNOTATIONS_PATH`).
197
+ Un constat sans ancre suit exactement le chemin d'avant.
198
+
123
199
  **Prompts** — les 10 skills de `skills/` exposés comme prompts MCP (`/better-layout`,
124
200
  `/better-interface`, `/review-ai-product`…). Chaque prompt est *self-contained* :
125
201
  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
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * LA MATRICE D'AUTONOMIE — une définition, un endroit.
3
+ *
4
+ * Trois régimes croisés avec la réversibilité d'un geste. Consommée par
5
+ * `review-agent-control` (qui juge) et `design-agent-experience` (qui produit).
6
+ * Personne ne la recopie : deux copies d'une matrice, et le juge finira par
7
+ * reprocher ce que le producteur vient d'autoriser.
8
+ *
9
+ * ELLE NE DÉCLARE PAS SES INTERDITS, ELLE LES DÉRIVE.
10
+ * « Un geste irréversible ne peut pas être en régime "agir seul" » n'est pas
11
+ * une consigne écrite quelque part qu'on pourrait oublier d'appliquer : c'est
12
+ * `regimeMaximal()` qui la rend impossible. Une règle déclarée se contourne en
13
+ * ne la lisant pas ; une règle dérivée se contourne en réécrivant la fonction,
14
+ * ce qui se voit.
15
+ *
16
+ * D'OÙ VIENT LA DOCTRINE. Du Vol.3 Ch.01 du Handbook, « Attribuer un niveau
17
+ * d'autonomie à chaque tool » : six niveaux, huit gates obligatoires, et la
18
+ * procédure mécanique — si un seul gate est vrai, confirmation obligatoire quel
19
+ * que soit le niveau nominal. Ce fichier ne réinvente pas cette doctrine, il la
20
+ * rend calculable. Les huit gates sont recopiés ICI et nulle part ailleurs dans
21
+ * le code : c'est leur seul domicile côté serveur.
22
+ *
23
+ * Aucune dépendance : ce module peut partir dans un bundle navigateur.
24
+ */
25
+ /* ═══ LES TROIS RÉGIMES ════════════════════════════════════════════════════ */
26
+ export const REGIMES = ["seul", "confirmer", "jamais"];
27
+ export const REGIME_INFO = {
28
+ seul: {
29
+ titre: "Agir seul",
30
+ ce_que_ca_veut_dire: "L'agent fait, et la personne l'apprend après coup. Le geste doit pouvoir être défait sans effort par qui le découvre.",
31
+ },
32
+ confirmer: {
33
+ titre: "Confirmer d'abord",
34
+ ce_que_ca_veut_dire: "L'agent prépare et montre CE QU'IL VA FAIRE — le plan, le diff — puis attend. Un bouton « OK » sur un texte vague n'est pas une confirmation.",
35
+ },
36
+ jamais: {
37
+ titre: "Ne jamais faire",
38
+ ce_que_ca_veut_dire: "L'agent ne dispose pas de ce geste. La personne le fait elle-même, dans son outil. Retirer l'outil vaut mieux que le garder derrière une confirmation qu'on finira par cliquer machinalement.",
39
+ },
40
+ };
41
+ /* ═══ LA RÉVERSIBILITÉ ═════════════════════════════════════════════════════ */
42
+ export const REVERSIBILITES = ["reversible", "couteux", "irreversible"];
43
+ export const REVERSIBILITE_INFO = {
44
+ reversible: {
45
+ titre: "Réversible",
46
+ test: "La personne peut le défaire seule, en quelques secondes, sans demander à personne.",
47
+ },
48
+ couteux: {
49
+ titre: "Coûteux à annuler",
50
+ test: "Ça se défait, mais il faut du temps, une autre personne, ou une opération qu'on n'a pas envie de refaire. Un message envoyé qu'il faut aller rattraper.",
51
+ },
52
+ irreversible: {
53
+ titre: "Irréversible",
54
+ test: "Une fois fait, c'est fait. Une donnée supprimée, un paiement passé, un courriel parti chez un client, une promesse publique. Le technique n'est pas le seul irréversible.",
55
+ },
56
+ };
57
+ /* ═══ LES HUIT GATES — Vol.3 Ch.01, « Les gates obligatoires » ═════════════ */
58
+ export const GATES = [
59
+ "publication-externe",
60
+ "modification-source-approuvee",
61
+ "suppression-ou-remplacement",
62
+ "changement-de-permissions",
63
+ "acces-donnees-sensibles",
64
+ "migration-ou-schema",
65
+ "cout-externe",
66
+ "decision-produit-irreversible",
67
+ ];
68
+ export const GATE_INFO = {
69
+ "publication-externe": "La donnée sort du produit — envoi, export vers un tiers, publication.",
70
+ "modification-source-approuvee": "Le geste touche une source que quelqu'un a validée.",
71
+ "suppression-ou-remplacement": "Quelque chose disparaît ou est écrasé. Fusionner, c'est supprimer.",
72
+ "changement-de-permissions": "Qui peut faire quoi change.",
73
+ "acces-donnees-sensibles": "Le geste lit ou déplace des données qu'on ne montre pas à tout le monde.",
74
+ "migration-ou-schema": "La forme des données change.",
75
+ "cout-externe": "Le geste engage de l'argent, un quota, ou un abonnement.",
76
+ "decision-produit-irreversible": "Le geste engage le produit d'une manière qu'on ne défera pas.",
77
+ };
78
+ /**
79
+ * LE RÉGIME LE PLUS LIBRE QU'UN GESTE PUISSE PRENDRE.
80
+ *
81
+ * La règle du Handbook, rendue calculable : un seul gate vrai, et la
82
+ * confirmation devient obligatoire quel que soit le niveau nominal. La
83
+ * réversibilité s'y ajoute — un geste irréversible n'atteint jamais « agir
84
+ * seul », même sans gate, parce que « je le défais » n'existe pas pour lui.
85
+ *
86
+ * Ce que cette fonction NE fait pas : choisir. Elle donne le plafond ; descendre
87
+ * en dessous est un choix d'équipe parfaitement légitime, et la sur-confirmation
88
+ * se juge ailleurs (voir `ecarts`).
89
+ */
90
+ export function regimeMaximal(reversibilite, gates) {
91
+ if (reversibilite === "irreversible")
92
+ return "confirmer";
93
+ if (gates.length > 0)
94
+ return "confirmer";
95
+ return "seul";
96
+ }
97
+ const RANG = { seul: 0, confirmer: 1, jamais: 2 };
98
+ /** Un régime est-il plus libre qu'un autre ? */
99
+ export function plusLibreQue(a, b) {
100
+ return RANG[a] < RANG[b];
101
+ }
102
+ /**
103
+ * Les écarts entre ce qui est DÉCLARÉ et ce que la matrice permet.
104
+ *
105
+ * DEUX SENS, ET LE SECOND SURPREND. Trop libre est le défaut qu'on attend :
106
+ * un geste irréversible laissé en « agir seul ». Mais la sur-confirmation en
107
+ * est un aussi, et il est plus répandu — demander confirmation de tout revient
108
+ * à ne rien protéger. Au bout de la trentième boîte de dialogue, la personne
109
+ * clique sans lire, et la confirmation qui comptait vraiment passe avec les
110
+ * autres. C'est le Vol.3 Ch.01 qui l'écrit : regrouper les actions à faible
111
+ * risque, exposer un plan avant les actions coûteuses.
112
+ *
113
+ * Un juge qui féliciterait la prudence excessive laisserait passer le défaut
114
+ * qui rend toutes les gardes inutiles.
115
+ */
116
+ export function ecarts(gestes) {
117
+ const out = [];
118
+ for (const g of gestes) {
119
+ const plafond = regimeMaximal(g.reversibilite, g.gates);
120
+ if (plusLibreQue(g.regime, plafond)) {
121
+ const raisons = [];
122
+ if (g.reversibilite === "irreversible")
123
+ raisons.push("il est irréversible");
124
+ for (const gate of g.gates)
125
+ raisons.push(`il déclenche « ${gate} » (${GATE_INFO[gate]})`);
126
+ out.push({
127
+ kind: "trop-libre",
128
+ geste: g.nom,
129
+ regime: g.regime,
130
+ plafond,
131
+ /* Un irréversible laissé sans garde est bloquant : on engage ce qu'on
132
+ ne défera pas. Un geate simplement gaté est critique : la règle
133
+ existe et sera contournée, elle n'est pas absente. Même arbitrage
134
+ que le modèle de sévérité de la maison. */
135
+ severite: g.reversibilite === "irreversible" ? "bloquant" : "critique",
136
+ preuve: `« ${g.nom} » est déclaré en régime « ${REGIME_INFO[g.regime].titre} » alors que ` +
137
+ `${raisons.join(" et ")} — la matrice plafonne à « ${REGIME_INFO[plafond].titre} ».`,
138
+ });
139
+ continue;
140
+ }
141
+ if (g.regime === "confirmer" && plafond === "seul") {
142
+ out.push({
143
+ kind: "sur-confirmation",
144
+ geste: g.nom,
145
+ severite: "majeur",
146
+ preuve: `« ${g.nom} » est réversible et ne déclenche aucun gate, et pourtant il demande ` +
147
+ "confirmation. Chaque confirmation inutile use celles qui comptent : au bout de la " +
148
+ "trentième, on clique sans lire.",
149
+ });
150
+ }
151
+ if (!g.raison || g.raison.trim().length === 0) {
152
+ out.push({
153
+ kind: "sans-raison",
154
+ geste: g.nom,
155
+ severite: "mineur",
156
+ preuve: `« ${g.nom} » porte un régime sans raison écrite : dans six mois, personne ne saura pourquoi.`,
157
+ });
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * La matrice rendue lisible — l'artefact que le producteur pose et que le juge lit.
164
+ *
165
+ * Une ligne par geste, et la colonne qui compte est la dernière : ce que la
166
+ * matrice permet, à côté de ce qui est déclaré. Un tableau qui ne montrerait
167
+ * que le déclaré se relit sans jamais se contester.
168
+ */
169
+ export function rendreMatrice(gestes) {
170
+ const l = [];
171
+ l.push("Geste | Réversibilité | Gates | Régime déclaré | Plafond de la matrice");
172
+ for (const g of gestes) {
173
+ const plafond = regimeMaximal(g.reversibilite, g.gates);
174
+ l.push([
175
+ g.nom,
176
+ REVERSIBILITE_INFO[g.reversibilite].titre,
177
+ g.gates.length ? g.gates.join(", ") : "aucun",
178
+ REGIME_INFO[g.regime].titre,
179
+ REGIME_INFO[plafond].titre + (plusLibreQue(g.regime, plafond) ? " ← DÉPASSÉ" : ""),
180
+ ].join(" | "));
181
+ }
182
+ return l.join("\n");
183
+ }
184
+ /**
185
+ * Les questions qu'on pose quand un geste est ambigu.
186
+ *
187
+ * Le producteur les emploie, une à la fois, et n'y répond JAMAIS à la place de
188
+ * l'équipe : la réversibilité d'un geste dépend du métier, pas du code. « Est-ce
189
+ * qu'un envoi se rattrape chez vous ? » n'a pas de réponse générale.
190
+ */
191
+ export const QUESTIONS_AMBIGU = {
192
+ reversibilite: "Si l'agent fait ça par erreur, qu'est-ce qu'il faut pour revenir en arrière — un clic, une heure, ou rien ne le défait ?",
193
+ gates: "Est-ce que ce geste fait sortir une donnée, en supprime une, change qui peut faire quoi, ou engage de l'argent ?",
194
+ regime: "Tu préfères que l'agent le fasse seul et te le dise après, qu'il te montre ce qu'il va faire et attende, ou qu'il ne l'ait simplement pas ?",
195
+ };