novahiz 0.2.5 → 0.3.2

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 (54) hide show
  1. package/NOTICE.md +18 -6
  2. package/README.md +12 -7
  3. package/adapters/README.md +1 -1
  4. package/adapters/opencode/agent/novahiz.md +2 -2
  5. package/adapters/opencode/instructions.md +2 -1
  6. package/adapters/opencode/novahiz.ts +336 -40
  7. package/catalog/categories.json +282 -9
  8. package/catalog/overrides.json +727 -0
  9. package/catalog/providers.json +63 -10
  10. package/catalog/rules.json +42 -9
  11. package/docs/ARCHITECTURE.md +2 -2
  12. package/docs/CATALOG.md +4 -4
  13. package/docs/CLASSIFICATION.md +3 -1
  14. package/docs/CONFIGURATION.md +5 -5
  15. package/docs/CONVERGENCE-2026-09-25.md +5 -0
  16. package/docs/GATE.md +26 -15
  17. package/docs/HARNESSES.md +10 -6
  18. package/docs/INSTALL.md +1 -0
  19. package/docs/PROVIDERS.md +21 -6
  20. package/docs/ROADMAPS.md +29 -0
  21. package/docs/RULES.md +7 -4
  22. package/install/bootstrap.mjs +29 -24
  23. package/install/install.mjs +56 -19
  24. package/install/lib.mjs +20 -4
  25. package/mcp/novahiz-tools/README.md +5 -0
  26. package/mcp/novahiz-tools/index.mjs +78 -7
  27. package/novahiz.config.example.json +4 -3
  28. package/package.json +3 -2
  29. package/skills/gate/SKILL.md +16 -1
  30. package/skills/novahiz-analyse/SKILL.md +2 -2
  31. package/skills/novahiz-audit/SKILL.md +1 -1
  32. package/skills/novahiz-code-review/SKILL.md +1 -1
  33. package/skills/novahiz-converge/SKILL.md +1 -1
  34. package/skills/novahiz-gate/SKILL.md +2 -1
  35. package/skills/novahiz-implement/SKILL.md +2 -2
  36. package/skills/novahiz-plan/SKILL.md +2 -2
  37. package/skills/novahiz-planner/SKILL.md +5 -3
  38. package/skills/novahiz-task/SKILL.md +1 -1
  39. package/skills/prompt-rewriter/SKILL.md +18 -13
  40. package/src/cli.ts +24 -4
  41. package/src/commands/context.ts +43 -4
  42. package/src/commands/doctor.ts +26 -8
  43. package/src/commands/gate.ts +66 -85
  44. package/src/commands/graft.ts +14 -9
  45. package/src/commands/inspect.ts +28 -2
  46. package/src/commands/task.ts +30 -1
  47. package/src/exec.ts +38 -10
  48. package/src/gate-repair.ts +87 -0
  49. package/src/gate.ts +156 -14
  50. package/src/graft.ts +56 -23
  51. package/src/ledger.ts +12 -2
  52. package/src/memory.ts +14 -1
  53. package/src/prompt-rewriter.ts +357 -24
  54. package/src/spec.ts +19 -6
@@ -8,9 +8,119 @@ import { join, relative, resolve } from "node:path";
8
8
  // ~/.config/opencode/plugins/ and cannot resolve ../../src/*.
9
9
  type RewriteResult = { original: string; rewritten: string; sourceLanguage: string; wasRewritten: boolean };
10
10
 
11
+ function fold(value: string): string {
12
+ return value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
13
+ }
14
+
15
+ function rx(words: string, flags: string): RegExp {
16
+ return new RegExp(`(?<![\\p{L}\\p{N}])(?:${words})(?![\\p{L}\\p{N}])`, flags);
17
+ }
18
+
19
+ function marker(words: string): RegExp {
20
+ return rx(words, "iu");
21
+ }
22
+
23
+ function word(words: string): RegExp {
24
+ return rx(words, "giu");
25
+ }
26
+
27
+ const DETECT_MIN_SCORE = 3;
28
+ const LANG_ORDER = ["fr", "es", "de", "pt"];
29
+
30
+ const LANG_PATTERNS: Record<string, Array<[RegExp, number]>> = {
31
+ fr: [
32
+ [marker("corriger|corrige"), 3], [marker("ajouter|ajoute|ajoutez"), 3],
33
+ [marker("supprimer|supprime|supprimez"), 3], [marker("creer|creez|creation"), 3],
34
+ [marker("developper|developpe"), 3], [marker("implementer|implemente"), 3],
35
+ [marker("refactoriser|refactorise"), 3], [marker("configurer"), 3],
36
+ [marker("installer"), 3], [marker("ameliorer|amelioration"), 3],
37
+ [marker("optimiser"), 3], [marker("verifier|verifie"), 3],
38
+ [marker("expliquer|explique"), 3], [marker("pourquoi"), 3],
39
+ [marker("comment faire"), 3],
40
+ [marker("dans"), 2], [marker("avec"), 2], [marker("pour"), 2], [marker("vous"), 2],
41
+ [marker("nous"), 2], [marker("votre|notre"), 2], [marker("fonction|fonctions"), 2],
42
+ [marker("classe|classes"), 2], [marker("fichier|fichiers"), 2],
43
+ [marker("probleme|problemes"), 2], [marker("erreur|erreurs"), 2],
44
+ [marker("serveur"), 2], [marker("donnees"), 2], [marker("utilisateur|utilisateurs"), 2],
45
+ [marker("ecran"), 2], [marker("bouton"), 2], [marker("formulaire"), 2],
46
+ [marker("securite"), 2], [marker("reseau"), 2], [marker("requete"), 2],
47
+ [marker("besoin"), 2], [marker("faites|fait"), 2],
48
+ [marker("le"), 1], [marker("la"), 1], [marker("les"), 1], [marker("des"), 1],
49
+ [marker("du"), 1], [marker("de"), 1], [marker("un"), 1], [marker("une"), 1],
50
+ [marker("est"), 1], [marker("ne"), 1], [marker("pas"), 1],
51
+ ],
52
+ es: [
53
+ [marker("hacer"), 3], [marker("crear|cree"), 3], [marker("anadir"), 3],
54
+ [marker("borrar"), 3], [marker("arreglar"), 3], [marker("corregir"), 3],
55
+ [marker("explicar"), 3], [marker("necesito"), 3], [marker("quiero"), 3],
56
+ [marker("tengo"), 3], [marker("base de datos"), 3], [marker("inicio de sesion"), 3],
57
+ [marker("para"), 2], [marker("con"), 2], [marker("pero"), 2], [marker("muy"), 2],
58
+ [marker("tambien"), 2], [marker("pagina|paginas"), 2], [marker("servidor"), 2],
59
+ [marker("usuario|usuarios"), 2], [marker("archivo|archivos"), 2], [marker("codigo"), 2],
60
+ [marker("pantalla"), 2], [marker("mensaje"), 2], [marker("conexion"), 2],
61
+ [marker("datos"), 2], [marker("diseno"), 2], [marker("aplicacion"), 2],
62
+ [marker("de"), 1], [marker("la"), 1], [marker("el"), 1], [marker("los"), 1],
63
+ [marker("las"), 1], [marker("un"), 1], [marker("una"), 1], [marker("es"), 1],
64
+ [marker("en"), 1], [marker("por"), 1], [marker("que"), 1], [marker("se"), 1],
65
+ [marker("al"), 1], [marker("lo"), 1],
66
+ ],
67
+ de: [
68
+ [marker("bitte"), 3], [marker("machen"), 3], [marker("erstellen|erstelle"), 3],
69
+ [marker("hinzufugen"), 3], [marker("loschen"), 3], [marker("beheben|behebt"), 3],
70
+ [marker("debuggen"), 3], [marker("testen"), 3], [marker("erklaren|erklare"), 3],
71
+ [marker("warum"), 3], [marker("nicht"), 3], [marker("muss"), 3], [marker("soll"), 3],
72
+ [marker("funktion|funktionen"), 2], [marker("klasse"), 2], [marker("datei|dateien"), 2],
73
+ [marker("fehler"), 2], [marker("seite|seiten"), 2], [marker("datenbank"), 2],
74
+ [marker("anwendung|anwendungen"), 2], [marker("benutzer"), 2], [marker("anmelden"), 2],
75
+ [marker("einloggen"), 2], [marker("variablen"), 2], [marker("speichern"), 2],
76
+ [marker("anzeigen"), 2], [marker("verbessern"), 2], [marker("optimieren"), 2],
77
+ [marker("implementieren"), 2], [marker("schreiben"), 2], [marker("suchen"), 2],
78
+ [marker("starten"), 2],
79
+ [marker("der"), 1], [marker("die"), 1], [marker("das"), 1], [marker("den"), 1],
80
+ [marker("dem"), 1], [marker("ein"), 1], [marker("eine"), 1], [marker("ist"), 1],
81
+ [marker("mit"), 1], [marker("fur"), 1], [marker("und"), 1], [marker("auf"), 1],
82
+ [marker("zu"), 1],
83
+ ],
84
+ pt: [
85
+ [marker("criar|crie"), 3], [marker("fazer"), 3], [marker("adicionar"), 3],
86
+ [marker("corrigir"), 3], [marker("preciso"), 3], [marker("quero"), 3],
87
+ [marker("banco de dados"), 3],
88
+ [marker("pagina|paginas"), 2], [marker("usuario|usuarios"), 2],
89
+ [marker("arquivo|arquivos"), 2], [marker("codigo"), 2], [marker("mensagem"), 2],
90
+ [marker("tela"), 2], [marker("configurar"), 2], [marker("instalar"), 2],
91
+ [marker("melhorar"), 2], [marker("conectar"), 2], [marker("senha"), 2],
92
+ [marker("rede"), 2], [marker("funcao"), 2], [marker("nao"), 2], [marker("tambem"), 2],
93
+ [marker("uma"), 1], [marker("um"), 1], [marker("para"), 1], [marker("com"), 1],
94
+ [marker("que"), 1], [marker("na"), 1], [marker("os"), 1], [marker("as"), 1],
95
+ [marker("ao"), 1], [marker("mais"), 1],
96
+ ],
97
+ };
98
+
11
99
  function detectLanguage(prompt: string): string {
12
100
  if (/[\u0600-\u06FF]/.test(prompt)) return "ar";
13
- return "en";
101
+ const folded = fold(prompt);
102
+ let bestLang = "en";
103
+ let bestScore = 0;
104
+ let bestStrong = false;
105
+ for (const lang of LANG_ORDER) {
106
+ let score = 0;
107
+ let strong = false;
108
+ for (const [pattern, weight] of LANG_PATTERNS[lang]) {
109
+ if (pattern.test(folded)) {
110
+ score += weight;
111
+ if (weight >= 3) strong = true;
112
+ }
113
+ }
114
+ if (score < DETECT_MIN_SCORE) continue;
115
+ const wins =
116
+ bestScore === 0 ? true : strong !== bestStrong ? strong : score > bestScore;
117
+ if (wins) {
118
+ bestLang = lang;
119
+ bestScore = score;
120
+ bestStrong = strong;
121
+ }
122
+ }
123
+ return bestLang;
14
124
  }
15
125
 
16
126
  const AR_EN: Array<[RegExp, string]> = [
@@ -33,22 +143,75 @@ const AR_EN: Array<[RegExp, string]> = [
33
143
  [/تسجيل الدخول/g, "login"], [/خطأ/g, "bug"], [/اداء/g, "performance"], [/امان/g, "security"],
34
144
  ];
35
145
 
36
- function rewritePrompt(prompt: string): RewriteResult {
37
- const sourceLanguage = detectLanguage(prompt);
38
- if (sourceLanguage === "en") {
39
- let r = prompt.trim().replace(/^I\s+want\s+to\s+/i, "").replace(/^I\s+need\s+to\s+/i, "")
40
- .replace(/^Can\s+you\s+/i, "").replace(/^Could\s+you\s+/i, "").replace(/^Please\s+/i, "")
41
- .replace(/^I\s+would\s+like\s+to\s+/i, "").replace(/^It\s+would\s+be\s+great\s+if\s+you\s+could\s+/i, "")
42
- .replace(/[.!?]+$/, "").trim();
43
- return { original: prompt, rewritten: r, sourceLanguage, wasRewritten: r !== prompt };
44
- }
146
+ const FR_EN: Array<[RegExp, string]> = [
147
+ [word("base de donnees|base de données"), "database"],
148
+ [word("page d'accueil|page daccueil|page d accueil"), "homepage"],
149
+ [word("mot de passe"), "password"],
150
+ [word("site web"), "website"],
151
+ [word("mise a jour|mise à jour"), "update"],
152
+ [word("tableau de bord"), "dashboard"],
153
+ [word("code source"), "source code"],
154
+ [word("en temps reel|en temps réel"), "realtime"],
155
+ [word("formulaire de contact"), "contact form"],
156
+ [word("point d'entree|point d entree|point d'entrée"), "entry point"],
157
+ [word("se deconnecter|se déconnecter"), "logout"],
158
+ [word("se connecter"), "login"],
159
+ [word("corriger|corrige"), "fix"], [word("ajouter|ajoute|ajoutez"), "add"],
160
+ [word("supprimer|supprime|supprimez|effacer|enlever"), "remove"],
161
+ [word("creer|créer|creez|créez"), "create"], [word("developper|développer"), "develop"],
162
+ [word("implementer|implémenter"), "implement"], [word("refactoriser|refactorer"), "refactor"],
163
+ [word("configurer"), "configure"], [word("installer"), "install"], [word("migrer"), "migrate"],
164
+ [word("ameliorer|améliorer"), "improve"], [word("amelioration|amélioration"), "improvement"],
165
+ [word("optimiser"), "optimize"], [word("verifier|vérifier|verifie"), "verify"],
166
+ [word("expliquer|explique"), "explain"], [word("afficher|affiche|montrer|montre"), "display"],
167
+ [word("masquer|cacher"), "hide"], [word("activer"), "enable"],
168
+ [word("desactiver|désactiver"), "disable"], [word("ecrire|écrire"), "write"],
169
+ [word("sauvegarder|enregistrer"), "save"], [word("utiliser|utilise"), "use"],
170
+ [word("remplacer"), "replace"], [word("simplifier"), "simplify"],
171
+ [word("nettoyer"), "clean up"], [word("tester|teste|testez"), "test"],
172
+ [word("deboguer|déboguer"), "debug"], [word("compiler"), "compile"],
173
+ [word("deployer|déployer"), "deploy"], [word("rechercher|chercher"), "search"],
174
+ [word("telecharger|télécharger"), "download"], [word("envoyer"), "send"],
175
+ [word("ouvrir"), "open"], [word("fermer"), "close"],
176
+ [word("fonction|fonctions"), "function"], [word("classe|classes"), "class"],
177
+ [word("methode|méthode"), "method"], [word("fichier|fichiers"), "file"],
178
+ [word("probleme|problème|problemes"), "issue"], [word("erreur|erreurs"), "error"],
179
+ [word("serveur|serveurs"), "server"], [word("donnees|données"), "data"],
180
+ [word("ecran|écran"), "screen"], [word("bouton|boutons"), "button"],
181
+ [word("formulaire|formulaires"), "form"], [word("champ|champs"), "field"],
182
+ [word("lien|liens"), "link"], [word("modele|modèle"), "model"],
183
+ [word("schema|schéma"), "schema"], [word("reseau|réseau"), "network"],
184
+ [word("securite|sécurité"), "security"], [word("utilisateur|utilisateurs"), "user"],
185
+ [word("requete|requête"), "request"], [word("reponse|réponse"), "response"],
186
+ [word("performances"), "performance"], [word("tableau"), "table"],
187
+ [word("liste"), "list"], [word("boucle"), "loop"], [word("authentification"), "auth"],
188
+ [word("inscription"), "register"], [word("connexion"), "login"],
189
+ [word("dans"), "in"], [word("avec"), "with"], [word("pour"), "for"], [word("sur"), "on"],
190
+ [word("sans"), "without"], [word("aussi"), "also"], [word("mais"), "but"],
191
+ [word("pas"), "not"], [word("plus"), "more"], [word("tres|très"), "very"],
192
+ [word("comment"), "how to"], [word("pourquoi"), "why"], [word("besoin"), "need"],
193
+ ];
194
+
195
+ function translateTerms(prompt: string, lang: string): string {
196
+ const map = lang === "ar" ? AR_EN : lang === "fr" ? FR_EN : null;
197
+ if (!map) return prompt;
45
198
  let result = prompt;
46
- for (const [pattern, replacement] of AR_EN) result = result.replace(pattern, replacement);
47
- let r = result.trim().replace(/^I\s+want\s+to\s+/i, "").replace(/^I\s+need\s+to\s+/i, "")
199
+ for (const [pattern, replacement] of map) result = result.replace(pattern, replacement);
200
+ return result;
201
+ }
202
+
203
+ function optimizeEnglish(prompt: string): string {
204
+ return prompt.trim()
205
+ .replace(/^I\s+want\s+to\s+/i, "").replace(/^I\s+need\s+to\s+/i, "")
48
206
  .replace(/^Can\s+you\s+/i, "").replace(/^Could\s+you\s+/i, "").replace(/^Please\s+/i, "")
49
207
  .replace(/^I\s+would\s+like\s+to\s+/i, "").replace(/^It\s+would\s+be\s+great\s+if\s+you\s+could\s+/i, "")
50
208
  .replace(/[.!?]+$/, "").trim();
51
- return { original: prompt, rewritten: r, sourceLanguage, wasRewritten: true };
209
+ }
210
+
211
+ function rewritePrompt(prompt: string): RewriteResult {
212
+ const sourceLanguage = detectLanguage(prompt);
213
+ const rewritten = optimizeEnglish(translateTerms(prompt, sourceLanguage));
214
+ return { original: prompt, rewritten, sourceLanguage, wasRewritten: rewritten !== prompt };
52
215
  }
53
216
 
54
217
  // Inlined from src/autodocs.ts — same reason as prompt-rewriter: relative
@@ -229,12 +392,18 @@ const GATE = CONFIG.gate ?? {};
229
392
  // redirect the kill-switch to an unrelated variable (e.g. CI=false).
230
393
  // Single escape hatch name after brand rename.
231
394
  const ESCAPE = (process.env.NOVAHIZ_GATE ?? "").toLowerCase();
232
- // gate.enabled=false disables the plugin the same way the CLI gate does (see src/commands/gate.ts).
233
- // gate.mode (block/warn/audit) stays owned by the CLI: the plugin only forwards the gate exit code.
234
- const DISABLED =
235
- ["off", "0", "false", "no", "disabled"].includes(ESCAPE) || GATE.enabled === false;
395
+ // P0-B: gate.enabled=false is no longer honored — a writable config must not
396
+ // silently disable enforcement (same rule as the CLI and the MCP tool).
397
+ // Only the env escape turns the gate off; gate.mode stays owned by the CLI.
398
+ const DISABLED = ["off", "0", "false", "no", "disabled"].includes(ESCAPE);
236
399
  const GATE_TOOLS = new Set(
237
- (Array.isArray(GATE.tools) && GATE.tools.length > 0 ? GATE.tools : ["edit", "write", "patch", "apply_patch", "bash", "shell"]).map((tool) => tool.toLowerCase())
400
+ (Array.isArray(GATE.tools) && GATE.tools.length > 0
401
+ ? GATE.tools
402
+ // MINEUR#8: cron tools that carry/execute shell commands are gated too —
403
+ // cron_add_command_task was a bash-gate bypass. Keep in sync with
404
+ // DEFAULT_CONFIG.gate.tools (src/spec.ts) and novahiz.config.json.
405
+ : ["edit", "write", "patch", "apply_patch", "bash", "shell", "cron_add_command_task", "cron_update_command_task", "cron_update_task", "cron_run_task_now"]
406
+ ).map((tool) => tool.toLowerCase())
238
407
  );
239
408
 
240
409
  type RunResult = { status: number; stdout: string; stderr: string; spawnError?: string };
@@ -282,11 +451,78 @@ function isValidSessionId(id: unknown): id is string {
282
451
  return typeof id === "string" && id.trim().length > 0;
283
452
  }
284
453
 
454
+ // Inlined from src/gate-repair.ts — the installed plugin lives in
455
+ // ~/.config/opencode/plugins/ and cannot resolve ../../src/*.
456
+ type GateFailure = {
457
+ tool: string;
458
+ missingSkills: string[];
459
+ reasons: string[];
460
+ error: string | null;
461
+ };
462
+
463
+ function parseGateFailure(stdout: string): GateFailure | null {
464
+ let parsed: unknown;
465
+ try {
466
+ parsed = JSON.parse(stdout);
467
+ } catch {
468
+ return null;
469
+ }
470
+ if (!parsed || typeof parsed !== "object") return null;
471
+ const record = parsed as Record<string, unknown>;
472
+ const asStringArray = (value: unknown): string[] =>
473
+ Array.isArray(value)
474
+ ? value.filter((entry): entry is string => typeof entry === "string")
475
+ : [];
476
+ const tool = typeof record.tool === "string" && record.tool.length > 0 ? record.tool : "tool";
477
+ return {
478
+ tool,
479
+ missingSkills: asStringArray(record.missingSkills),
480
+ reasons: asStringArray(record.reasons),
481
+ error: typeof record.error === "string" ? record.error : null
482
+ };
483
+ }
484
+
485
+ function buildRepairDirective(failure: GateFailure, attempt: number): string {
486
+ const head = `Novahiz gate blocked ${failure.tool}.`;
487
+ const missing = failure.missingSkills;
488
+
489
+ if (missing.length === 0) {
490
+ const detail = failure.error ?? failure.reasons.join("; ") ?? "no reason reported";
491
+ return `${head}\nBlocked by rule, not by a missing skill: ${detail}\nResolve the listed rule, then retry once. Never bypass the gate.`;
492
+ }
493
+
494
+ if (attempt <= 1) {
495
+ const steps = missing.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
496
+ return [
497
+ `${head} Missing skills: ${missing.join(", ")}.`,
498
+ "AUTO-REPAIR — execute now, do not ask the user, do not stop:",
499
+ steps,
500
+ ` ${missing.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
501
+ "Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
502
+ ].join("\n");
503
+ }
504
+
505
+ return [
506
+ `${head} AUTO-REPAIR FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
507
+ "The loads did not register — diagnose instead of retrying:",
508
+ " 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
509
+ " 2. Realign the index (`novahiz sync`), then load the named skills again.",
510
+ "If the skill genuinely does not exist, report that honestly to the user and stop. Never bypass the gate."
511
+ ].join("\n");
512
+ }
513
+
285
514
  export const NovahizPlugin: Plugin = async ({ client }) => {
286
515
  const loadedBySession = new Map<string, Set<string>>();
287
516
  const categoriesBySession = new Map<string, string[]>();
288
517
  const enforcementBySession = new Map<string, string>();
289
518
  const lastSeenBySession = new Map<string, number>();
519
+ // P0-B: reason of the last failed classify per session — gate tool calls are
520
+ // refused while set, instead of running with empty categories (fail-open).
521
+ const classifyFailedBySession = new Map<string, string>();
522
+ // Auto-repair: denial count per `session|tool|missing set`. A first denial
523
+ // carries the repair protocol; an identical repeat escalates to diagnosis
524
+ // instead of looping. Cleared on a successful call of the same tool.
525
+ const repairAttemptsBySession = new Map<string, number>();
290
526
  const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
291
527
 
292
528
  const log = async (level: "info" | "warn", message: string): Promise<void> => {
@@ -302,6 +538,11 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
302
538
  categoriesBySession.delete(sessionID);
303
539
  enforcementBySession.delete(sessionID);
304
540
  lastSeenBySession.delete(sessionID);
541
+ classifyFailedBySession.delete(sessionID);
542
+ // Auto-repair keys are prefixed with the session ID — drop them too.
543
+ for (const key of repairAttemptsBySession.keys()) {
544
+ if (key.startsWith(`${sessionID}|`)) repairAttemptsBySession.delete(key);
545
+ }
305
546
  };
306
547
 
307
548
  // Sessions only vanish from memory on session.deleted, which may never arrive.
@@ -314,8 +555,9 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
314
555
  }
315
556
  };
316
557
 
317
- if (DISABLED)
318
- await log("info", GATE.enabled === false ? "Gate disabled via config (gate.enabled=false)" : "Gate disabled via environment escape");
558
+ if (GATE.enabled === false)
559
+ await log("warn", "gate.enabled=false in config is ignored; enforcement stays active. Use NOVAHIZ_GATE=off to disable the gate.");
560
+ if (DISABLED) await log("info", "Gate disabled via environment escape");
319
561
 
320
562
  return {
321
563
  config: async (input) => {
@@ -397,9 +639,13 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
397
639
  await log("info", `Prompt rewritten: ${rewrite.sourceLanguage} → English ("${classifyText.slice(0, 80)}")`);
398
640
  }
399
641
 
400
- const result = run(["classify", classifyText]);
642
+ // P0-B: the prompt travels on stdin. On argv it allowed option
643
+ // injection (--home), broke past the Windows 32k limit, and was
644
+ // readable in the process list.
645
+ const result = run(["classify", "--stdin"], classifyText);
401
646
  if (result.status !== 0) {
402
- await log("warn", `Classify failed (exit ${result.status}), no enforcement injected: ${result.stderr.trim().slice(0, 200)}`);
647
+ classifyFailedBySession.set(input.sessionID, `classify exit ${result.status}`);
648
+ await log("warn", `Classify failed (exit ${result.status}), gate tool calls refused for this session: ${result.stderr.trim().slice(0, 200)}`);
403
649
  return;
404
650
  }
405
651
  let parsed: {
@@ -413,15 +659,18 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
413
659
  try {
414
660
  parsed = JSON.parse(result.stdout) as typeof parsed;
415
661
  } catch {
416
- await log("warn", "Classify returned invalid JSON, no enforcement injected");
662
+ classifyFailedBySession.set(input.sessionID, "classify returned invalid JSON");
663
+ await log("warn", "Classify returned invalid JSON, gate tool calls refused for this session");
417
664
  return;
418
665
  }
419
666
  // M5: the `as` cast is compile-time only — validate the shape at runtime
420
667
  // so a malformed classify response cannot silently disable enforcement.
421
668
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.categories)) {
422
- await log("warn", "Classify returned unexpected structure, no enforcement injected");
669
+ classifyFailedBySession.set(input.sessionID, "classify returned an unexpected structure");
670
+ await log("warn", "Classify returned unexpected structure, gate tool calls refused for this session");
423
671
  return;
424
672
  }
673
+ classifyFailedBySession.delete(input.sessionID);
425
674
  const categories = (parsed.categories ?? []).map((entry) => entry.id);
426
675
  categoriesBySession.set(input.sessionID, categories);
427
676
  const primary = parsed.primary ?? categories[0] ?? null;
@@ -434,7 +683,7 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
434
683
  "[Novahiz enforcement]",
435
684
  `Categories detected: ${categories.join(", ") || "none"}${primary ? ` (primary: ${primary})` : ""}`
436
685
  ];
437
- if (rewrite.wasRewritten) {
686
+ if (rewrite.sourceLanguage !== "en") {
438
687
  lines.push(`User language: ${rewrite.sourceLanguage} — respond in this language, not English.`);
439
688
  }
440
689
  if (roadmap) {
@@ -457,12 +706,13 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
457
706
  }
458
707
  }
459
708
  lines.push("The gate blocks edit/write/patch/apply_patch/bash/shell until the required skills are loaded via skill({name:\"...\"}).");
460
- lines.push("The gate is content-aware: novahiz-humanizer, ui-slop-remover and ui-craft-rules are required only on frontend design tasks (R13).");
709
+ lines.push("The gate is content-aware: novahiz-humanizer, ui-slop-remover and ui-craft-rules are required only on frontend design tasks (R13), and impeccable on the same design selectors (R14).");
461
710
  lines.push("Config edited = opencode restart required (config read at import).");
462
711
  lines.push("Memory lives in project-memory/ under the project root (cwd): index.json + fixed-size slots (8000 chars / 200 lines) with compact → archive → new-slot rotation. Use the MCP memory_* tools to read and append.");
463
712
  enforcementBySession.set(input.sessionID, lines.join("\n"));
464
713
  } catch (error) {
465
- await log("warn", `chat.message hook failed, no enforcement injected: ${String(error).slice(0, 200)}`);
714
+ classifyFailedBySession.set(input.sessionID, "chat.message hook error");
715
+ await log("warn", `chat.message hook failed, gate tool calls refused for this session: ${String(error).slice(0, 200)}`);
466
716
  return;
467
717
  }
468
718
  },
@@ -481,29 +731,57 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
481
731
 
482
732
  "tool.execute.before": async (input, output) => {
483
733
  try {
484
- // H-Plugin: validate session ID before using it — prevents undefined/null
485
- // from being passed to spawnSync env (throws on Windows).
486
- if (!isValidSessionId(input.sessionID)) return;
734
+ const tool = input.tool.toLowerCase();
735
+ const gated = !DISABLED && GATE_TOOLS.has(tool);
736
+ // P0-B: an invalid session ID must not bypass the gate. Gate tools and
737
+ // skill loads are refused; tools that need no enforcement still pass.
738
+ if (!isValidSessionId(input.sessionID)) {
739
+ if (gated || (!DISABLED && tool === "skill")) {
740
+ throw new Error(
741
+ `Novahiz gate blocked ${input.tool}: invalid session ID — loaded skills cannot be tracked. Fix the session or set NOVAHIZ_GATE=off to disable.`
742
+ );
743
+ }
744
+ return;
745
+ }
487
746
  touch(input.sessionID);
488
747
  if (!loadedBySession.has(input.sessionID)) loadedBySession.set(input.sessionID, new Set());
489
748
  const loaded = loadedBySession.get(input.sessionID)!;
490
749
 
491
- if (input.tool.toLowerCase() === "skill") {
492
- const args = output.args as { name?: string; skill?: string } | undefined;
493
- const name = args?.name ?? args?.skill;
494
- if (name) {
495
- loaded.add(String(name));
496
- // H3: surface session-load failures — otherwise the gate blocks later
497
- // with no trace of why the skill was never recorded.
498
- const loadResult = run(["session-load", "--session", input.sessionID, "--skill", String(name)]);
750
+ if (tool === "skill") {
751
+ const args = output.args as { name?: unknown; skill?: unknown } | undefined;
752
+ const raw = args?.name ?? args?.skill;
753
+ if (raw !== undefined && raw !== null && typeof raw !== "string") {
754
+ throw new Error("Novahiz gate blocked the skill load: the skill name must be a string.");
755
+ }
756
+ const name = typeof raw === "string" ? raw.trim() : "";
757
+ // P0-B: no commas or spaces — the gate re-splits --loaded on commas,
758
+ // so a loose name could inject extra "loaded" skills.
759
+ if (name.length > 0 && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) {
760
+ throw new Error(`Novahiz gate blocked the skill load: invalid skill name "${name.slice(0, 64)}".`);
761
+ }
762
+ if (name.length > 0) {
763
+ // P0-B: record only after session-load validated the name against
764
+ // the installed index — an unverified name never counts as loaded.
765
+ // H3 stays: failures are surfaced in the log instead of vanishing.
766
+ const loadResult = run(["session-load", "--session", input.sessionID, "--skill", name]);
499
767
  if (loadResult.status !== 0) {
500
- await log("warn", `session-load failed for skill ${name}: ${(loadResult.stderr || "").trim().slice(0, 200)}`);
768
+ await log("warn", `session-load failed for skill ${name}, not recorded: ${(loadResult.stderr || loadResult.stdout || "").trim().slice(0, 200)}`);
769
+ } else {
770
+ loaded.add(name);
501
771
  }
502
772
  }
503
773
  return;
504
774
  }
505
775
 
506
- if (DISABLED || !GATE_TOOLS.has(input.tool.toLowerCase())) return;
776
+ if (!gated) return;
777
+ // P0-B: a failed classify would empty the session categories and
778
+ // neutralize the prompt-scoped rules — refuse instead of failing open.
779
+ const classifyFailure = classifyFailedBySession.get(input.sessionID);
780
+ if (classifyFailure) {
781
+ throw new Error(
782
+ `Novahiz gate blocked ${input.tool}: prompt classification failed (${classifyFailure}). Fix the install (run "novahiz sync", check catalog/) and send a new message, or set NOVAHIZ_GATE=off to disable.`
783
+ );
784
+ }
507
785
 
508
786
  const categories = categoriesBySession.get(input.sessionID) ?? [];
509
787
  const result = run(
@@ -537,6 +815,18 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
537
815
  );
538
816
  }
539
817
  if (result.status === 2) {
818
+ // Auto-repair: a structured denial becomes an executable directive
819
+ // (load the named skills, retry the same call, resume the task).
820
+ // Counting identical denials turns a failed repair into a diagnosis
821
+ // instead of an infinite retry loop. The denial itself still stands
822
+ // until the gate CLI sees the skills — nothing is granted here.
823
+ const failure = parseGateFailure(result.stdout);
824
+ if (failure) {
825
+ const key = `${input.sessionID}|${failure.tool}|${[...failure.missingSkills].sort().join(",")}`;
826
+ const attempt = (repairAttemptsBySession.get(key) ?? 0) + 1;
827
+ repairAttemptsBySession.set(key, attempt);
828
+ throw new Error(buildRepairDirective(failure, attempt));
829
+ }
540
830
  throw new Error(`Novahiz gate blocked ${input.tool}.\n${result.stdout}`);
541
831
  }
542
832
  if (result.status !== 0) {
@@ -544,6 +834,12 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
544
834
  `Novahiz gate unavailable (exit ${result.status}). Fix the install (run sync, check catalog/) or set the escape variable to disable.\n${result.stderr}`
545
835
  );
546
836
  }
837
+ // The call is allowed: the repair converged — drop this session's
838
+ // denial counters so the next task starts a fresh cycle.
839
+ const allowedPrefix = `${input.sessionID}|${input.tool.toLowerCase()}|`;
840
+ for (const key of repairAttemptsBySession.keys()) {
841
+ if (key.startsWith(allowedPrefix)) repairAttemptsBySession.delete(key);
842
+ }
547
843
  } catch (error) {
548
844
  if (error instanceof Error && error.message.startsWith("Novahiz gate")) throw error;
549
845
  // H2: fail-closed — unknown gate errors deny, they never bypass.