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
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # designwithai-mcp
2
+
3
+ Le serveur MCP du Handbook Design With AI. Il expose la **source de vérité
4
+ éditoriale** — le même `handbook.json` que sert le site — à Claude ou à un IDE,
5
+ en **Resources** (pour lire) et en **Tools** (pour agir).
6
+
7
+ Principe : les outils **imposent la méthode du Handbook**, ils ne distribuent pas
8
+ des réponses. `review_artifact` exige une référence d'artefact ; les outils de
9
+ plan renvoient des **gates à franchir**, pas des solutions clé en main.
10
+
11
+ ## Une seule source, deux surfaces
12
+
13
+ Le serveur ne contient aucun contenu : il lit `handbook.json`. Une modification
14
+ d'un chapitre, régénérée au build du site, apparaît ici comme sur le site — sans
15
+ copie divergente. C'est garanti par construction.
16
+
17
+ Ordre de résolution du fichier :
18
+
19
+ 1. `HANDBOOK_PATH` — chemin d'un fichier (erreur s'il manque) ;
20
+ 2. `out/handbook.json` puis `public/handbook.json` du dépôt ;
21
+ 3. `HANDBOOK_URL`, sinon `https://designwithai.fr/handbook.json`.
22
+
23
+ Il faut donc soit avoir buildé le site (`STATIC_EXPORT=1 npx next build` →
24
+ `out/handbook.json`), soit pointer `HANDBOOK_URL` sur une instance en ligne.
25
+
26
+ ## Installer
27
+
28
+ Le serveur est publié sur npm : rien à cloner, rien à construire.
29
+
30
+ ```bash
31
+ claude mcp add designwithai \
32
+ --env DWAI_TOKEN=dwai_TON_JETON \
33
+ -- npx -y designwithai-mcp
34
+ ```
35
+
36
+ Le jeton se génère depuis ton compte sur https://designwithai.fr/compte — c'est
37
+ lui qui fait remonter les audits dans ton tableau de bord. Sans lui, le serveur
38
+ fonctionne quand même : seule la remontée est désactivée.
39
+
40
+ **Où le serveur écrit.** Tes revues, décisions, profil et état vont dans
41
+ `.designwithai/` **du dossier depuis lequel ton éditeur a été lancé** — donc dans
42
+ ton projet, avec le produit qu'ils décrivent. `DWAI_WORKSPACE` permet d'imposer
43
+ un autre dossier.
44
+
45
+ **Recherche sémantique (facultative).** Le classement par mots-clés fonctionne
46
+ d'origine. Pour y ajouter la recherche sémantique, installe la bibliothèque de
47
+ modèles à côté — elle pèse plusieurs centaines de mégaoctets, d'où son caractère
48
+ facultatif :
49
+
50
+ ```bash
51
+ npm i @huggingface/transformers
52
+ ```
53
+
54
+ Son absence ne provoque aucune erreur : la recherche retombe sur les mots-clés.
55
+
56
+ ## Développer depuis le dépôt
57
+
58
+ ```bash
59
+ cd mcp
60
+ npm install
61
+ npm run build # → dist/server.js
62
+ ```
63
+
64
+ ## Brancher sur Claude Desktop
65
+
66
+ Dans `claude_desktop_config.json` :
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "designwithai": {
72
+ "command": "node",
73
+ "args": ["/Users/boomer/designwithai/mcp/dist/server.js"],
74
+ "env": {
75
+ "HANDBOOK_PATH": "/Users/boomer/designwithai/out/handbook.json"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Brancher sur Claude Code
83
+
84
+ ```bash
85
+ claude mcp add designwithai \
86
+ --env HANDBOOK_PATH=/Users/boomer/designwithai/out/handbook.json \
87
+ -- node /Users/boomer/designwithai/mcp/dist/server.js
88
+ ```
89
+
90
+ ## Ce qu'il expose
91
+
92
+ **Resources** — chaque unité du Handbook à son URI `handbook://…` (chapitres,
93
+ règles, anti-patterns, prompts, checklists, scorecards), en lecture.
94
+
95
+ **Tools**
96
+
97
+ | Outil | Ce qu'il fait | Garde-fou |
98
+ |---|---|---|
99
+ | `search_handbook` | Cherche dans le Handbook | Chaque résultat cite source + version |
100
+ | `recommend_workflow` | Objectif → séquence de gates | Des gates, pas une solution |
101
+ | `review_artifact` | Artefact → grille de critères à confronter | **Refuse sans référence d'artefact** |
102
+ | `build_action_plan` | Situation → plan de gates priorisés | Ce qu'il faut produire, pas des conseils |
103
+ | `set_profile` | Déclare le profil projet pour la session | Recos contextualisées sans le redonner |
104
+ | `save_review` | Enregistre un issue register de skill comme artefact suivi | **Refuse sans référence d'artefact** |
105
+ | `list_reviews` | Liste les reviews (par artefact / skill) | — |
106
+ | `compare_reviews` | Régression entre deux passes : résolus / nouveaux / persistants | — |
107
+ | `clear_gate` / `reopen_gate` / `gate_status` | Boucle d'état des gates (ferme un gate avec preuve) | `clear_gate` **refuse sans artefact** |
108
+ | `audit_project` | Flow bout-en-bout : orchestrateur + profil + ancrage cité + traçage | **Refuse sans artefact** |
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** |
111
+ | `generate_decision_record` | Décision traçable (ADR) persistée | — |
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
+ | `next_gate` | Prochain gate conscient des PRÉRÉQUIS (graphe) | ne propose que les gates débloqués |
114
+ | `connect_repo` | Lit un dépôt local (stack, composants, tokens, sources) | distant = via les MCP dédiés |
115
+
116
+ **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
+
118
+ Le profil est chargé au démarrage depuis `.designwithai/profile.json` (ou `DWAI_PROFILE` / `DWAI_PROFILE_PATH`). Une fois posé, son `delivery_mode` sert de défaut à tous les outils. Les reviews sont stockées dans `.designwithai/reviews/` (surchargeable par `DWAI_REVIEWS_PATH`).
119
+
120
+ Tous acceptent `delivery_mode` (`ai-builder` / `classique` / `hybride`) et
121
+ filtrent comme le fait le site.
122
+
123
+ **Prompts** — les 10 skills de `skills/` exposés comme prompts MCP (`/better-layout`,
124
+ `/better-interface`, `/review-ai-product`…). Chaque prompt est *self-contained* :
125
+ socle commun + corps du skill + invocation renseignée par les arguments
126
+ (`artifact`, `objective`, `delivery_mode`, `source_of_truth`, `constraints`).
127
+ Chargés depuis `../skills` par défaut, surchargeable par `SKILLS_PATH`.
128
+
129
+ ## Brancher le pont éditeur → compte
130
+
131
+ `push_audit` fait remonter une review enregistrée vers le compte de l'utilisateur,
132
+ pour qu'elle apparaisse dans son cockpit sur n'importe quel appareil. Deux
133
+ variables, dans la config de l'éditeur — **jamais dans le dépôt** :
134
+
135
+ ```bash
136
+ claude mcp add designwithai \
137
+ --env HANDBOOK_PATH=/Users/boomer/designwithai/out/handbook.json \
138
+ --env DWAI_TOKEN=dwai_… \
139
+ --env DWAI_INGEST_URL=https://hbugvurcdmimhvvfdlos.supabase.co/functions/v1/ingest-audit \
140
+ -- node /Users/boomer/designwithai/mcp/dist/server.js
141
+ ```
142
+
143
+ Le jeton se génère dans **Compte → « Connecter mon éditeur »**, s'affiche une seule
144
+ fois, et se révoque au même endroit. Il ne donne aucun droit de lecture : il ne
145
+ sert qu'à écrire un audit sous le compte qui l'a créé. La clé `service_role`,
146
+ elle, n'existe que dans l'Edge Function `ingest-audit` — ni ici, ni dans le
147
+ bundle du site.
148
+
149
+ ## Développer
150
+
151
+ ```bash
152
+ npm run dev # tsx, sans build
153
+ ```
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Connecteurs — la réalité du projet.
3
+ *
4
+ * Le MCP apporte la MÉTHODE (handbook.json). Les connecteurs apportent la
5
+ * RÉALITÉ : ce qui est réellement codé, documenté, livré. Les connecteurs
6
+ * DISTANTS (Figma, Supabase live, GitHub) passent par leurs propres serveurs MCP
7
+ * — inutile de les réimplémenter. Ce module fait le connecteur qu'aucun autre ne
8
+ * fait : la lecture d'un DÉPÔT LOCAL, pour ancrer l'analyse dans le vrai code.
9
+ */
10
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
11
+ import path from "node:path";
12
+ function dirExists(p) {
13
+ return existsSync(p) && statSync(p).isDirectory();
14
+ }
15
+ export function readRepoSnapshot(repoPath) {
16
+ if (!existsSync(repoPath))
17
+ return { error: `Chemin introuvable : ${repoPath}` };
18
+ let name;
19
+ let deps = {};
20
+ const pkgPath = path.join(repoPath, "package.json");
21
+ if (existsSync(pkgPath)) {
22
+ try {
23
+ const p = JSON.parse(readFileSync(pkgPath, "utf8"));
24
+ name = p.name;
25
+ deps = { ...(p.dependencies ?? {}), ...(p.devDependencies ?? {}) };
26
+ }
27
+ catch {
28
+ /* package.json illisible */
29
+ }
30
+ }
31
+ const has = (k) => Object.keys(deps).some((d) => d.includes(k));
32
+ const framework = [];
33
+ if (has("next"))
34
+ framework.push("Next.js");
35
+ else if (has("react"))
36
+ framework.push("React");
37
+ if (has("vue"))
38
+ framework.push("Vue");
39
+ if (has("svelte"))
40
+ framework.push("Svelte");
41
+ if (has("tailwind"))
42
+ framework.push("Tailwind");
43
+ if (has("@supabase"))
44
+ framework.push("Supabase");
45
+ if (has("storybook"))
46
+ framework.push("Storybook");
47
+ const tokensPresent = ["tokens.json", "src/app/globals.css", "app/globals.css", "src/styles/globals.css", "styles/globals.css"].some((f) => existsSync(path.join(repoPath, f)));
48
+ let compDir = null;
49
+ let count = 0;
50
+ let sample = [];
51
+ for (const c of ["src/components", "components", "app/components", "src/lib/components"]) {
52
+ if (dirExists(path.join(repoPath, c))) {
53
+ const files = readdirSync(path.join(repoPath, c)).filter((f) => /\.(tsx|jsx|vue|svelte)$/.test(f));
54
+ compDir = c;
55
+ count = files.length;
56
+ sample = files.slice(0, 8).map((f) => f.replace(/\.\w+$/, ""));
57
+ break;
58
+ }
59
+ }
60
+ const sources = {
61
+ repository: "connecté",
62
+ storybook: dirExists(path.join(repoPath, ".storybook")) || has("storybook") ? "connecté" : "absent",
63
+ supabase: dirExists(path.join(repoPath, "supabase")) || has("@supabase") ? "connecté" : "absent",
64
+ tokens: tokensPresent ? "connecté" : "absent",
65
+ figma: "distant", // ne se lit pas localement — via le serveur MCP Figma
66
+ production: "distant", // via l'URL de prod déclarée dans le profil
67
+ };
68
+ return {
69
+ path: repoPath,
70
+ name,
71
+ framework,
72
+ dependencies: Object.keys(deps).length,
73
+ sources,
74
+ components: { dir: compDir, count, sample },
75
+ };
76
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Les décisions traçables (Architecture/Design Decision Records).
3
+ *
4
+ * Transforme une analyse en décision persistante et citable : contexte, preuves,
5
+ * options, décision, risques acceptés, critères de réévaluation, et les règles
6
+ * du Handbook utilisées (avec leur version). C'est cette traçabilité qui rend le
7
+ * système utile aux ÉQUIPES, pas seulement à un utilisateur isolé.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { workspaceDir } from "./paths.js";
12
+ export function decisionsDir() {
13
+ return process.env.DWAI_DECISIONS_PATH ?? workspaceDir("decisions");
14
+ }
15
+ function slug(s) {
16
+ return s
17
+ .toLowerCase()
18
+ .normalize("NFD")
19
+ .replace(/[̀-ͯ]/g, "")
20
+ .replace(/[^a-z0-9]+/g, "-")
21
+ .replace(/^-|-$/g, "")
22
+ .slice(0, 40);
23
+ }
24
+ export function formatDecision(d) {
25
+ const list = (items) => items && items.length ? items.map((x) => `- ${x}`).join("\n") : "- —";
26
+ return (`# ${d.title}\n\n` +
27
+ `_${d.id} · ${d.created_at}_\n\n` +
28
+ (d.artifact_ref ? `**Artefact** : ${d.artifact_ref}\n\n` : "") +
29
+ `## Contexte\n${d.context || "—"}\n\n` +
30
+ `## Preuves\n${list(d.evidence)}\n\n` +
31
+ `## Options envisagées\n${list(d.options)}\n\n` +
32
+ `## Décision\n${d.decision}\n\n` +
33
+ `## Risques acceptés\n${list(d.accepted_risks)}\n\n` +
34
+ `## Critères de réévaluation\n${list(d.review_criteria)}\n\n` +
35
+ `## Règles du Handbook utilisées\n${list(d.sources)}\n`);
36
+ }
37
+ export function saveDecision(input, now) {
38
+ const stamp = now.toISOString();
39
+ const compact = stamp.replace(/[:.]/g, "-").replace("T", "_").replace(/Z$/, "").slice(0, 16);
40
+ const dir = decisionsDir();
41
+ mkdirSync(dir, { recursive: true });
42
+ let id = `${slug(input.title)}-${compact}`;
43
+ for (let n = 2; existsSync(path.join(dir, `${id}.md`)); n++)
44
+ id = `${slug(input.title)}-${compact}-${n}`;
45
+ const decision = { ...input, id, created_at: stamp };
46
+ const markdown = formatDecision(decision);
47
+ writeFileSync(path.join(dir, `${id}.md`), markdown);
48
+ writeFileSync(path.join(dir, `${id}.json`), `${JSON.stringify(decision, null, 2)}\n`);
49
+ return { decision, path: path.join(dir, `${id}.md`), markdown };
50
+ }
51
+ export function listDecisions() {
52
+ const dir = decisionsDir();
53
+ if (!existsSync(dir))
54
+ return [];
55
+ const out = [];
56
+ for (const f of readdirSync(dir)) {
57
+ if (!f.endsWith(".json"))
58
+ continue;
59
+ try {
60
+ out.push(JSON.parse(readFileSync(path.join(dir, f), "utf8")));
61
+ }
62
+ catch {
63
+ /* ignoré */
64
+ }
65
+ }
66
+ return out.sort((a, b) => b.created_at.localeCompare(a.created_at));
67
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Recherche sémantique — côté runtime.
3
+ *
4
+ * Charge les embeddings pré-calculés (handbook-embeddings.json) SI présents, et
5
+ * embed la requête avec le même modèle local. Combiné aux mots-clés, ça permet
6
+ * de trouver par le sens : « produire une interface » remonte les chapitres
7
+ * prompt-to-UI même sans mot commun.
8
+ *
9
+ * Dégradation gracieuse : pas de fichier d'embeddings → le module reste inerte
10
+ * (jamais de chargement du modèle), la recherche retombe sur les mots-clés.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import path from "node:path";
15
+ let corpus; // undefined = pas encore lu
16
+ function embeddingsPath() {
17
+ if (process.env.HANDBOOK_EMBEDDINGS_PATH)
18
+ return process.env.HANDBOOK_EMBEDDINGS_PATH;
19
+ const here = path.dirname(fileURLToPath(import.meta.url));
20
+ return path.join(here, "..", "handbook-embeddings.json");
21
+ }
22
+ export function loadCorpusEmbeddings() {
23
+ if (corpus !== undefined)
24
+ return corpus;
25
+ const p = embeddingsPath();
26
+ if (!existsSync(p)) {
27
+ corpus = null;
28
+ return null;
29
+ }
30
+ try {
31
+ corpus = JSON.parse(readFileSync(p, "utf8"));
32
+ }
33
+ catch {
34
+ corpus = null;
35
+ }
36
+ return corpus;
37
+ }
38
+ export function semanticAvailable() {
39
+ return loadCorpusEmbeddings() !== null;
40
+ }
41
+ // Le modèle n'est chargé qu'à la première requête, et seulement si des embeddings
42
+ // existent. Import dynamique : la lib ML n'entre jamais dans le graphe sinon.
43
+ let extractor = null;
44
+ async function getExtractor() {
45
+ if (extractor)
46
+ return extractor;
47
+ const mod = (await import("@huggingface/transformers"));
48
+ extractor = await mod.pipeline("feature-extraction", "Xenova/paraphrase-multilingual-MiniLM-L12-v2");
49
+ return extractor;
50
+ }
51
+ /**
52
+ * La bibliothèque de modèles pèse plus d'un demi-gigaoctet. L'imposer à
53
+ * l'installation reviendrait à faire attendre un demi-giga à quelqu'un qui veut
54
+ * juste brancher son éditeur — elle est donc facultative, et son absence doit se
55
+ * traduire par une recherche par mots-clés, pas par une panne.
56
+ *
57
+ * Une seule tentative : si l'import échoue, on ne le retente pas à chaque
58
+ * requête. La recherche par mots-clés, elle, n'a jamais cessé de fonctionner.
59
+ */
60
+ let semanticUsable = null;
61
+ export async function trySemanticScores(query) {
62
+ if (semanticUsable === false)
63
+ return null;
64
+ if (!semanticAvailable())
65
+ return null;
66
+ try {
67
+ const scores = semanticScores(await embedQuery(query));
68
+ semanticUsable = true;
69
+ return scores;
70
+ }
71
+ catch {
72
+ semanticUsable = false;
73
+ return null;
74
+ }
75
+ }
76
+ export async function embedQuery(text) {
77
+ const ex = await getExtractor();
78
+ const out = await ex(text.slice(0, 2000), { pooling: "mean", normalize: true });
79
+ return Array.from(out.data);
80
+ }
81
+ /** Vecteurs normalisés → le cosinus est le simple produit scalaire. */
82
+ export function cosine(a, b) {
83
+ let dot = 0;
84
+ const n = Math.min(a.length, b.length);
85
+ for (let i = 0; i < n; i++)
86
+ dot += a[i] * b[i];
87
+ return dot;
88
+ }
89
+ /** uri → similarité cosinus pour un vecteur de requête. */
90
+ export function semanticScores(queryVec) {
91
+ const c = loadCorpusEmbeddings();
92
+ const m = new Map();
93
+ if (!c)
94
+ return m;
95
+ for (const uri in c)
96
+ m.set(uri, cosine(queryVec, c[uri]));
97
+ return m;
98
+ }
package/dist/evals.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Le banc d'éval des skills.
3
+ *
4
+ * Ce que le Handbook prêche (Vol.2 Ch.06 : « gèle un dataset d'éval »), les
5
+ * skills doivent le tenir. Ce module charge un corpus par skill (`skills/<skill>/
6
+ * evals/cases.json`) et SCORE une review candidate contre sa golden review :
7
+ * détection, faux positifs, respect du périmètre, verdict, refus. Les résultats
8
+ * s'agrègent en une scorecard — le score qui rend « assurance qualité » crédible.
9
+ *
10
+ * L'exécution du skill (LLM) est faite par Claude ; ce module fournit le corpus
11
+ * (aveugle, sans golden) et la mesure. Tout est déterministe et testable.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { workspaceDir, skillsRoot } from "./paths.js";
16
+ import { matches } from "./finding-identity.js";
17
+ function skillsDir() {
18
+ return skillsRoot();
19
+ }
20
+ export function loadCases(skill) {
21
+ const p = path.join(skillsDir(), skill, "evals", "cases.json");
22
+ if (!existsSync(p))
23
+ return [];
24
+ try {
25
+ return JSON.parse(readFileSync(p, "utf8"));
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ }
31
+ export function getCase(caseId) {
32
+ const dir = skillsDir();
33
+ if (!existsSync(dir))
34
+ return null;
35
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
+ if (!entry.isDirectory())
37
+ continue;
38
+ const found = loadCases(entry.name).find((c) => c.id === caseId);
39
+ if (found)
40
+ return found;
41
+ }
42
+ return null;
43
+ }
44
+ // ─── Scoring ─────────────────────────────────────────────────────────────────
45
+ function findingText(f) {
46
+ return [f.location, f.category, f.summary, f.correction].filter(Boolean).join(" ");
47
+ }
48
+ /**
49
+ * Le matcher n'habite plus ici.
50
+ *
51
+ * Mots vides, mots de contenu, recouvrement et plancher de deux mots partagés
52
+ * vivent désormais dans `finding-identity.ts`, avec la définition de
53
+ * l'équivalence entre deux constats. Trois surfaces s'en servent — ce banc,
54
+ * `compare_reviews` et le cockpit du site — et elles en avaient trois versions
55
+ * qui divergeaient. Une seule définition, sinon deux écrans donnent deux
56
+ * chiffres pour la même réalité.
57
+ */
58
+ function phraseMatched(phrase, findings) {
59
+ return findings.some((f) => matches(phrase, findingText(f)));
60
+ }
61
+ /**
62
+ * Un finding inventé ne compte comme FAUX POSITIF que s'il est SIGNIFICATIF :
63
+ * sévérité majeure et au-delà. Un audit de bout en bout (les orchestrateurs)
64
+ * produit légitimement des notes de faible sévérité sur un produit sain — les
65
+ * compter rendrait tout cas `conformant` inatteignable. Sévérité absente ou
66
+ * inconnue : traitée comme significative — le doute ne protège pas le skill.
67
+ */
68
+ function isSignificant(f) {
69
+ const s = f.severity;
70
+ return s !== "mineur" && s !== "note";
71
+ }
72
+ export function scoreCase(c, cand, now) {
73
+ const g = c.golden;
74
+ const detectedArr = g.expected_findings.filter((p) => phraseMatched(p, cand.findings));
75
+ const missed = g.expected_findings.filter((p) => !phraseMatched(p, cand.findings));
76
+ const forbidden_hits = g.forbidden_findings.filter((p) => phraseMatched(p, cand.findings));
77
+ const detection_rate = g.expected_findings.length
78
+ ? detectedArr.length / g.expected_findings.length
79
+ : 1;
80
+ const refusal_ok = (g.should_refuse ?? false) === (cand.refused ?? false);
81
+ // Un verdict attendu mais absent est un ÉCHEC : le verdict fait partie de ce que
82
+ // le banc prouve. Aucun verdict attendu → la condition ne s'applique pas.
83
+ const verdict_ok = !g.expected_verdict ? true : cand.verdict === g.expected_verdict;
84
+ const scope_ok = forbidden_hits.length === 0;
85
+ const extras = cand.findings.filter((f) => !g.expected_findings.some((p) => matches(p, findingText(f))));
86
+ const extra_findings = extras.length;
87
+ const extra_significant = extras.filter(isSignificant).length;
88
+ // Passe si : refus correct, périmètre respecté, verdict conforme quand il est
89
+ // attendu, détection ≥ 75 %, et (cas conformant ou hors-périmètre) aucun faux
90
+ // positif significatif — les notes de faible sévérité restent permises.
91
+ const noFalsePositiveNeeded = c.kind === "conformant" || c.kind === "out-of-scope";
92
+ const passed = refusal_ok &&
93
+ scope_ok &&
94
+ verdict_ok &&
95
+ detection_rate >= 0.75 &&
96
+ (!noFalsePositiveNeeded || extra_significant === 0) &&
97
+ (!g.should_refuse || Boolean(cand.refused));
98
+ return {
99
+ case_id: c.id,
100
+ skill: c.skill,
101
+ kind: c.kind,
102
+ expected: g.expected_findings.length,
103
+ detected: detectedArr.length,
104
+ missed,
105
+ detection_rate,
106
+ forbidden_hits,
107
+ scope_ok,
108
+ verdict_ok,
109
+ refusal_ok,
110
+ extra_findings,
111
+ extra_significant,
112
+ passed,
113
+ scored_at: now.toISOString(),
114
+ };
115
+ }
116
+ // ─── Stockage & agrégation ───────────────────────────────────────────────────
117
+ function resultsPath(skill) {
118
+ const base = process.env.DWAI_EVALS_PATH ?? workspaceDir("evals");
119
+ return path.join(base, `${skill}.json`);
120
+ }
121
+ export function saveScore(score) {
122
+ const p = resultsPath(score.skill);
123
+ mkdirSync(path.dirname(p), { recursive: true });
124
+ let store = {};
125
+ if (existsSync(p)) {
126
+ try {
127
+ store = JSON.parse(readFileSync(p, "utf8"));
128
+ }
129
+ catch {
130
+ store = {};
131
+ }
132
+ }
133
+ store[score.case_id] = score;
134
+ writeFileSync(p, `${JSON.stringify(store, null, 2)}\n`);
135
+ return p;
136
+ }
137
+ /** Anciens résultats (scorés avant la règle de sévérité) : repli sur extra_findings. */
138
+ function significantExtras(s) {
139
+ const v = s.extra_significant;
140
+ return v ?? s.extra_findings;
141
+ }
142
+ export function scorecard(skill) {
143
+ const total = loadCases(skill).length;
144
+ const p = resultsPath(skill);
145
+ let store = {};
146
+ if (existsSync(p)) {
147
+ try {
148
+ store = JSON.parse(readFileSync(p, "utf8"));
149
+ }
150
+ catch {
151
+ store = {};
152
+ }
153
+ }
154
+ const scores = Object.values(store);
155
+ const n = scores.length;
156
+ const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
157
+ const withExpected = scores.filter((s) => s.expected > 0);
158
+ const noDefect = scores.filter((s) => s.kind === "conformant" || s.kind === "out-of-scope");
159
+ return {
160
+ skill,
161
+ cases_total: total,
162
+ cases_tested: n,
163
+ detection_rate: avg(withExpected.map((s) => s.detection_rate)),
164
+ false_positive_rate: noDefect.length === 0
165
+ ? null
166
+ : noDefect.filter((s) => significantExtras(s) > 0).length / noDefect.length,
167
+ scope_adherence: n === 0 ? null : scores.filter((s) => s.scope_ok).length / n,
168
+ refusal_accuracy: n === 0 ? null : scores.filter((s) => s.refusal_ok).length / n,
169
+ passed: scores.filter((s) => s.passed).length,
170
+ last_validated: n === 0 ? null : scores.map((s) => s.scored_at).sort().at(-1) ?? null,
171
+ };
172
+ }